Sklepy najbliżej ciebie - geografia z GeoDjango

Tworzymy aplikację wskaującą sklepy najbliższe do podanego adresu

Boom na urządzenia mobilne jak i powszechny dostęp do internetu wywołał spore zapotrzebowanie na usługi związane z nawigacją, geolokalizacją i wyszukiwaniem spersonalizowanym pod kątem obecnego położenia geograficznego. Obecnie da się już dość "łatwo" tworzyć aplikacje oferujące takie funkcjonalności. Django oferuje cały ogromny wewnętrzny framework - geodjango.

W tym artykule zaprezentuję aplikację Django wykorzystującą kilka elementów geodjango do wyszukiwania najbliższych sklepów - na mapie i po odległości od podanego adresu. Aplikacja ta zaprezentuje podstawowe elementy GeoDjango (i całkiem ciekawe zastosowanie).

Baza danych

GeoDjango zaczyna się od bazy danych. Do obsługi geograficznych typów danych w bazach danych potrzebny jest odpowiedni silnik (GEOS), który trzeba zainstalować. Dokumentacja Django opisuje przypadki dla różnych baz danych. Ja wykorzystam PostgreSQL, które chyba najlepiej sobie z tym radzi.

W systemie musimy mieć zainstalowany serwer baz danych PostgreSQL oraz dodatek - postgis. Dla Ubuntu/Debiana i podobnych będzie to pakiet postgresql-*-postgis (gdzie * to numer wersji, w chwili pisania artykułu, w Ubuntu 12.04 postgresql-9.1-postgis).

Po zainstalowaniu pakietów trzeba skonfigurować postgresa i stworzyć postgisową bazę danych dla naszej aplikacji www. Jest to opisane w dokumentacji podlinkowanej powyżej. Ja przedstawię szybką ścieżkę dla Ubuntu/Debiana.

  • Odpalamy konsolę i przechodzimy na użytkownika postgres:
    sudo -i
    su postgres
    cd
  • Pobieramy (wget) skrypt konfiguracyjny - create_template_postgis-debian.sh z dokumentacji Django.
  • Odpalamy go:
    chmod 755 ./create_template_postgis-debian.sh
    ./create_template_postgis-debian.sh
  • Tworzymy postgisową bazę danych:
    createdb -T template_postgis NAZWA_BAZY_DANYCH
  • Ustawiamy hasło dla użytkownika postgres (jeżeli nie ustawione):
    psql NAZWA_BAZY_DANYCH
    I odpalamy:
    \password postgres
    Podajemy nowe hasło i gotowe.
Baza jest gotowa do użycia.

Konfiguracja Django

Konfiguracja projektu Django jest dość prosta - trzeba aby podać silnik dla baz postgisowych - django.contrib.gis.db.backends.postgis. W moim testowym projekcie wygląda to tak:
DATABASES = {
    'default': {
        'ENGINE': 'django.contrib.gis.db.backends.postgis',
        'NAME': 'postgis_test',
        'USER': 'postgres',
        'PASSWORD': 'postgres',
        'HOST': 'localhost',
    }
}
Do INSTALLED_APPS dodajemy też 'django.contrib.gis',. Możemy teraz wykonać syncdb i inne zaplanowane operacje.

GISowa aplikacja Django

Mamy specjalny typ bazy danych, mamy specjalną konfigurację Django - teraz trzeba to do czegoś wykorzystać. Zaprezentuję prostą aplikację - listę sklepów. Każdy sklep ma swoją nazwę i adres (adres, miasto). W modelu znajdzie się także GISowe pole na współrzędne geograficzne - a te są używane na mapach (Google Maps, OpenStreetMap) by zaznaczyć położenie obiekty. Są też używane przez GISowe bazy danych do wykonywania "geograficznych" zapytań - np. sortowania po odległości od danego punktu - co wykorzystamy do wyszukiwania sklepów po podanym adresie...

Modele

W normalnej aplikacji model "sklep" wyglądałby tak:
from django.db import models

class Shop(models.Model):
    name = models.CharField(max_length=200)
    address = models.CharField(max_length=100)
    city = models.CharField(max_length=50)

    def __unicode__(self):
        return self.name
Ale wspomniałem jeszcze o współrzędnych geograficznych. Nasz model wygląda tak:
from django.contrib.gis.db import models as gis_models
from django.contrib.gis import geos
from django.db import models

class Shop(models.Model):
    name = models.CharField(max_length=200)
    address = models.CharField(max_length=100)
    city = models.CharField(max_length=50)
    location = gis_models.PointField(u"longitude/latitude",
                                     geography=True, blank=True, null=True)

    gis = gis_models.GeoManager()
    objects = models.Manager()

    def __unicode__(self):
        return self.name
W tym modelu pojawia się PointField zdolne przechowywać współrzędne geograficzne. Pojawia się też dodatkowy manager gis - poprzez ten manager możemy wykonywać GISowe zapytania:
Shop.gis.filter() # GISowe
Shop.objects.filter() # Standardowe
Oczywiście jeżeli potrzebujemy możemy pod domyślny "objects" podłożyć manager GISowy.

Odpalamy syncdb co powinno stworzyć tabelę. Model jest gotowy.

Wprowadzamy dane

Korzystając z Panelu Admina dodałem kilka testowych sklepów:
Dodawanie testowych sklepów
Dodawanie testowych sklepów

Pole PointField pozostawiłem puste. Silniki map oferują usługi geokodowania adresów na współrzędne geograficzne. Ja wykorzystam geopy do geokodowania adresu na współrzędne geograficzne, które następnie trafią do pola PointField.

By dodać "automatyczne" geokodowanie adresu definiuję w modelu własną metodę save (można też użyć sygnału):
from django.contrib.gis.db import models as gis_models
from django.contrib.gis import geos
from django.db import models
from geopy.geocoders.google import Google
from geopy.geocoders.google import GQueryError

class Shop(models.Model):
    name = models.CharField(max_length=200)
    address = models.CharField(max_length=100)
    city = models.CharField(max_length=50)
    location = gis_models.PointField(u"longitude/latitude",
                                     geography=True, blank=True, null=True)

    gis = gis_models.GeoManager()
    objects = models.Manager()

    def __unicode__(self):
        return self.name

    def save(self, **kwargs):
        if not self.location:
            address = u'%s %s' % (self.city, self.address)
            address = address.encode('utf-8')
            geocoder = Google()
            try:
                _, latlon = geocoder.geocode(address)
            except (URLError, GQueryError, ValueError):
                pass
            else:
                point = "POINT(%s %s)" % (latlon[1], latlon[0])
                self.location = geos.fromstr(point)
        super(Shop, self).save()

Wykorzystuję geopy do geokodowania adresu, a następnie przypisuję obiekt typu "POINT" do pola PointField :) Trzeba uważać by nie pomylić długości z szerokością geograficzną.

Dla sklepów z poprawnym adresem powinniśmy zobaczyć wartości pojawiające się w polu location, np:
POINT (21.0122287000000014 52.2296756000000002)
Nasze dane są już kompletne - mamy współrzędne w bazie danych.

Z poziomu kodu Pythona współrzędne dostępne są jako location.x i location.y

GEO-Zapytania

Teraz wykorzystamy to "magiczne" GISowskie pole PointField do pobrania listy sklepów znajdujących się najbliżej podanego przez użytkownika adresu. Stosują tradycyjne rozwiązania jak dwa pola w bazie na długość i szerokość geograficzną nie byłoby to możliwe (a na pewno nie ładnie i wydajnie).

Stworzę teraz widok z formularzem na adres. Jeżeli adres zostanie podany to zgeokoduje go do współrzędnych za pomocą geopy. Mając współrzędne adresu będę mógł wykonać zapytanie do bazy danych.

Całość wygląda tak:
from urllib2 import URLError

from django.contrib.gis import geos
from django.contrib.gis import measure
from django.shortcuts import render_to_response
from django.template import RequestContext
from geopy.geocoders.google import Google
from geopy.geocoders.google import GQueryError

from shops import forms
from shops import models


def geocode_address(address):
    address = address.encode('utf-8')
    geocoder = Google()
    try:
        _, latlon = geocoder.geocode(address)
    except (URLError, GQueryError, ValueError):
        return None
    else:
        return latlon

def get_shops(longitude, latitude):
    current_point = geos.fromstr("POINT(%s %s)" % (longitude, latitude))
    distance_from_point = {'km': 10}
    shops = models.Shop.gis.filter(location__distance_lte=(current_point, measure.D(**distance_from_point)))
    shops = shops.distance(current_point).order_by('distance')
    return shops.distance(current_point)

def home(request):
    form = forms.AddressForm()
    shops = []
    if request.POST:
        form = forms.AddressForm(request.POST)
        if form.is_valid():
            address = form.cleaned_data['address']
            location = geocode_address(address)
            if location:
                latitude, longitude = location
                shops = get_shops(longitude, latitude)

    return render_to_response(
        'home.html',
        {'form': form, 'shops': shops},
        context_instance=RequestContext(request))

Mamy tutaj widok home odpowiedzialny za wyświetlanie wszystkiego. Do tego klasyczna obsługa formularza. Nowością jest kod zawarty w funkcji get_shops. Pobieram w niej sklepy, ale za pomocą GISowskiego managera - models.Shop.gis. Co więcej operuję na polu "location" - PointField. Po prostu każę pobrać sklepy o odległości mniejszej lub równej 10 km od podanego punktu - współrzędnych adresu wprowadzonego przez użytkownika. GISowskie pola mają bardzo rozbudowaną paletę metod i możliwych do przeprowadzenia na nich operacji. Tutaj prezentuję tylko kilka. Rozpiskę znajdziemy na chicagodjango.com.

Z pozostałych elementów mamy zwykły szablon:
<h1>Shop GEO-Test</h1>

<form method="post" action="./">
    {% csrf_token %}
    <table>
        {{ form }}
    </table>
    <input type="submit" value="Search" />
</form>

{% if shops %}
<h2>Shops near you</h2>
<ul>
    {% for shop in shops %}
    <li><b>{{ shop.name }}</b>: distance: {{ shop.distance }}</li>
    {% endfor %}
</ul>
{% endif %}
I klasę formularza:
from django import forms


class AddressForm(forms.Form):
    address = forms.CharField()
Teraz jeżeli wpiszę jakiś warszawski adres to wyświetli mi listę warszawskich sklepów zaczynając od najbliższego. Ten ze Szczecina jest za daleko.
GeoDjango w działaniu

GeoDjango w działaniu

Do uzyskania fajnej funkcjonalności musieliśmy przejść dość długą drogę. Z drugiej strony Django czyni ją czytelną i przyjemną w implementacji.

Mapy Google

Sama lista i jakieś odległości mogą nie wyglądać zbyt efektywnie. Mając współrzędne możemy bez problemu użyć jednego z systemu map, np. Google Maps to zaprezentowania adresu użytkownika i najbliższych sklepów na mapie. Wystarczy że do szablonu przekażę jeszcze współrzędnego podanego adresu i mogę dodać taki oto kod:
<script type="text/javascript" src="http://maps.google.com/maps/api/js?sensor=false"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.7.2/jquery.min.js"></script>
<script>
    $(document).ready(function() {
        var latlng = new google.maps.LatLng("{{ latitude }}", "{{ longitude }}");
        var mapOptions = {
            zoom: 15,
            center: latlng,
            mapTypeControl: false,
            navigationControlOptions: {style: google.maps.NavigationControlStyle.SMALL},
            mapTypeId: google.maps.MapTypeId.ROADMAP
        };
        map = new google.maps.Map($('.map')[0], mapOptions);
    
        var marker = new google.maps.Marker({
            position: latlng,
            map: map,
            title:"Jesteś tutaj"
        });
        
        {% for shop in shops %}
            latlng = new google.maps.LatLng("{{ shop.location.y }}", "{{ shop.location.x }}");
            new google.maps.Marker({
                position: latlng,
                map: map,
                title:"{{ shop.name }}"
            });
        {% endfor %}
    });
</script>
<div class="map" style="width: 400px; height: 400px;"></div>
Jest to bardzo szybkie użycie Google Maps ze znacznikami wskazującymi poszczególne miejsca. W kodzie produkcyjnym kod JavaScript byłby w pliku js, a dane mogły by być pobierane za pomocą żądania AJAX i oddzielnego widoku zwracającego potrzebne dane w formacie JSON, tudzież przekazywane w szablonie jako argumenty do funkcji JavaScript generującej mapę.
Sklepy i adres użytkownika zaznaczone na mapie

Sklepy i adres użytkownika zaznaczone na mapie

Więcej o API Google Maps na developers.google.com/maps/.
blog comments powered by Disqus

Kategorie

Strony