diff --git a/.travis.yml b/.travis.yml index 66faaa1..17dc054 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,13 @@ dist: xenial language: python - +sudo: true +addons: + postgresql: '10' + apt: + packages: + - postgresql-10-postgis-2.4 + - postgresql-10-postgis-2.4-scripts + - postgresql-client-10 matrix: fast_finish: true include: @@ -17,12 +24,18 @@ matrix: - { python: "3.7", env: DJANGO_VERSION=2.0 } - { python: "3.7", env: DJANGO_VERSION=2.1 } - { python: "3.7", env: DJANGO_VERSION=2.2 } +before_install: + - sudo -u postgres psql -c "CREATE USER testuser WITH PASSWORD 'password'" + - sudo -u postgres psql -c "ALTER ROLE testuser SUPERUSER" install: - pip install coverage - pip install coveralls - pip install -q Django==$DJANGO_VERSION + - pip install psycopg2 +before_script: + - psql -U postgres -c "create extension postgis" script: - coverage run --source=mapbox_location_field manage.py test after_success: diff --git a/README.md b/README.md index ddbd79d..a3f1354 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ * [Instalation](#instalation) * [Configuration](#configuration) * [Usage](#usage) + * [PLAIN (non-spatial) db](#plain-database) + * [SPATIAL db](#spatial-database) * [Customization](#customization) * [map_attrs](#map_attrs) * [bootstrap](#bootstrap) @@ -35,7 +37,8 @@ PS. Django 1.11 does not support Python 3.7 anymore. #### Browser support django-mapbox-location-field support all browsers, which are suported by mapbox gl js. Read more [here](https://docs.mapbox.com/help/troubleshooting/mapbox-browser-support/#mapbox-gl-js) - +#### Databases support +It should work with every **spatial** and **plain** (non-spatial) database, that works with django and geodjango. # Live demo Curious how it works and looks like ? See live demo on https://django-mapbox-location-field.herokuapp.com Demo app uses [django-bootstrap4](https://github.com/zostera/django-bootstrap4) for a little better looking form fields. @@ -58,44 +61,54 @@ MAPBOX_KEY = "pk.eyJ1IjoibWlnaHR5c2hhcmt5IiwiYSI6ImNqd2duaW4wMzBhcWI0M3F1MTRvbHB **PS. This above is only example access token. You have to paste here yours.** # Usage -* Just create some model with LocationField. -```python -from django.db import models -from mapbox_location_field.models import LocationField +* ### PLAIN DATABASE + * Just create some model with LocationField. + ```python + from django.db import models + from mapbox_location_field.models import LocationField -class SomeLocationModel(models.Model): - location = LocationField() + class SomeLocationModel(models.Model): + location = LocationField() -``` -* Create ModelForm -```python -from django import forms -from .models import Location + ``` +* ### SPATIAL DATABASE + * Just create some model with SpatialLocationField. + ```python + from django.db import models + from mapbox_location_field.models import SpatialLocationField -class LocationForm(forms.ModelForm): - class Meta: - model = Location - fields = "__all__" -``` -Of course you can also use CreateView, UpdateView or build Form yourself with mapbox_location_field.forms.LocationField + class SomeLocationModel(models.Model): + location = SpatialLocationField() + ``` +* Create ModelForm + ```python + from django import forms + from .models import Location + + class LocationForm(forms.ModelForm): + class Meta: + model = Location + fields = "__all__" + ``` + Of course you can also use CreateView, UpdateView or build Form yourself with `mapbox_location_field.forms.LocationField` or `mapbox_location_field.forms.SpatialLocationField` * Then just use it in html view. It can't be simpler! Paste this in your html head: -```django -{% load mapbox_location_field_tags %} -{% location_field_includes %} -{% include_jquery %} -``` + ```django + {% load mapbox_location_field_tags %} + {% location_field_includes %} + {% include_jquery %} + ``` * And this in your body: -```django -
- {% csrf_token %} - {{form}} - -
-{{ form.media }} -``` + ```django +
+ {% csrf_token %} + {{form}} + +
+ {{ form.media }} + ``` * Your form is ready! Start your website and see how it looks. If you want to change something look to the [customization](#customization) section. # Customization diff --git a/mapbox_location_field/admin.py b/mapbox_location_field/admin.py index 46c26c6..12f4741 100644 --- a/mapbox_location_field/admin.py +++ b/mapbox_location_field/admin.py @@ -1,8 +1,8 @@ from django.contrib import admin from django.forms import Media +from .models import LocationField, AddressAutoHiddenField, SpatialLocationField from .widgets import MapAdminInput, AddressAutoHiddenInput -from .models import LocationField, AddressAutoHiddenField class MapAdmin(admin.ModelAdmin): @@ -10,6 +10,7 @@ class MapAdmin(admin.ModelAdmin): change_form_template = "mapbox_location_field/admin_change.html" formfield_overrides = { LocationField: {'widget': MapAdminInput}, + SpatialLocationField: {'widget': MapAdminInput}, AddressAutoHiddenField: {"widget": AddressAutoHiddenInput, } } diff --git a/mapbox_location_field/forms.py b/mapbox_location_field/forms.py index 3ce791e..8a794f5 100644 --- a/mapbox_location_field/forms.py +++ b/mapbox_location_field/forms.py @@ -1,8 +1,34 @@ from django import forms +from django.contrib.gis.forms import PointField +from django.contrib.gis.geos import Point +from django.core.exceptions import ValidationError +from django.utils.translation import ugettext_lazy as _ from .widgets import MapInput, AddressAutoHiddenInput +def parse_location(location_string): + """parse and convert coordinates from string to tuple""" + + args = location_string.split(",") + if len(args) != 2: + raise ValidationError(_("Invalid input for a Location instance")) + + lat = args[0] + lng = args[1] + + try: + lat = float(lat) + except ValueError: + raise ValidationError(_("Invalid input for a Location instance. Latitude must be convertible to float ")) + try: + lng = float(lng) + except ValueError: + raise ValidationError(_("Invalid input for a Location instance. Longitude must be convertible to float ")) + + return lat, lng + + class LocationField(forms.CharField): """custom form field for picking location""" @@ -21,3 +47,30 @@ class AddressAutoHiddenField(forms.CharField): def __init__(self, **kwargs): super().__init__(**kwargs) self.label = "" + + +class SpatialLocationField(PointField): + """custom form field for picking location for spatial databases""" + + def __init__(self, *args, **kwargs): + map_attrs = kwargs.pop("map_attrs", None) + self.widget = MapInput(map_attrs=map_attrs, ) + + super().__init__(*args, **kwargs) + self.error_messages = {"required": "Please pick a location, it's required", } + + def clean(self, value): + try: + return Point(parse_location(value), srid=4326) + except (ValueError, ValidationError): + return None + + def to_python(self, value): + """Transform the value to a Geometry object.""" + if value in self.empty_values: + return None + + if isinstance(value, Point): + return value + + return Point(parse_location(value), srid=4326) diff --git a/mapbox_location_field/models.py b/mapbox_location_field/models.py index acad900..3d99f3c 100644 --- a/mapbox_location_field/models.py +++ b/mapbox_location_field/models.py @@ -1,30 +1,10 @@ -from django.core.exceptions import ValidationError +from django.contrib.gis.db.models import PointField from django.db import models from django.utils.translation import ugettext_lazy as _ -from .forms import AddressAutoHiddenField as AddressAutoHiddenFormField +from .forms import AddressAutoHiddenField as AddressAutoHiddenFormField, parse_location from .forms import LocationField as LocationFormField - - -def parse_location(location_string): - """parse and convert coordinates from string to tuple""" - args = location_string.split(",") - if len(args) != 2: - raise ValidationError(_("Invalid input for a Location instance")) - - lat = args[0] - lng = args[1] - - try: - lat = float(lat) - except ValueError: - raise ValidationError(_("Invalid input for a Location instance. Latitude must be convertible to float ")) - try: - lng = float(lng) - except ValueError: - raise ValidationError(_("Invalid input for a Location instance. Longitude must be convertible to float ")) - - return lat, lng +from .forms import SpatialLocationField as SpatialLocationFormField class LocationField(models.CharField): @@ -82,3 +62,24 @@ def formfield(self, **kwargs): defaults = {'form_class': AddressAutoHiddenFormField} defaults.update(kwargs) return models.Field.formfield(self, **defaults) + + +class SpatialLocationField(PointField): + """custom model field for storing location in spatial databases""" + + description = _("Location field for spatial databases, stores Points.") + + def __init__(self, *args, **kwargs): + self.map_attrs = kwargs.pop("map_attrs", {}) + super().__init__(*args, **kwargs) + + def deconstruct(self): + name, path, args, kwargs = super().deconstruct() + kwargs["map_attrs"] = self.map_attrs + return name, path, args, kwargs + + def formfield(self, **kwargs): + defaults = {'form_class': SpatialLocationFormField} + defaults.update(kwargs) + defaults.update({"map_attrs": self.map_attrs}) + return super().formfield(**defaults) diff --git a/mapbox_location_field/tests/test_forms.py b/mapbox_location_field/tests/test_forms.py index b530e56..c855476 100644 --- a/mapbox_location_field/tests/test_forms.py +++ b/mapbox_location_field/tests/test_forms.py @@ -1,6 +1,7 @@ +from django.contrib.gis.geos import Point from django.test import TestCase -from mapbox_location_field.forms import LocationField, AddressAutoHiddenField +from mapbox_location_field.forms import LocationField, AddressAutoHiddenField, SpatialLocationField from mapbox_location_field.widgets import MapInput, AddressAutoHiddenInput @@ -19,6 +20,46 @@ def test_passing_map_attrs(self): self.assertEqual(field.widget.map_attrs, {"some": "value", "and some": "cool value"}) +class SpatialLocationFieldTests(TestCase): + + def test_widget(self): + field = SpatialLocationField() + self.assertEqual(field.widget.__class__, MapInput().__class__) + + def test_error_messages(self): + field = SpatialLocationField() + self.assertEqual(field.error_messages["required"], "Please pick a location, it's required") + + def test_passing_map_attrs(self): + field = SpatialLocationField(map_attrs={"some": "value", "and some": "cool value"}) + self.assertEqual(field.widget.map_attrs, {"some": "value", "and some": "cool value"}) + + def test_clean(self): + field = SpatialLocationField() + point = field.clean("12,13") + + self.assertIsInstance(point, Point) + self.assertEqual(point.x, 12) + self.assertEqual(point.y, 13) + + point = field.clean("12") + self.assertIsNone(point) + + def test_to_python(self): + field = SpatialLocationField() + + for empty in field.empty_values: + self.assertIsNone(field.to_python(empty)) + + point = Point(12, 13) + self.assertIs(point, field.to_python(point)) + + point = field.to_python("12,13") + self.assertIsInstance(point, Point) + self.assertEqual(point.x, 12) + self.assertEqual(point.y, 13) + + class AddressAutoHiddenFieldTests(TestCase): def test_widget(self): field = AddressAutoHiddenField() diff --git a/mapbox_location_field/tests/test_models.py b/mapbox_location_field/tests/test_models.py index 5472f7a..d28a3ab 100644 --- a/mapbox_location_field/tests/test_models.py +++ b/mapbox_location_field/tests/test_models.py @@ -1,9 +1,10 @@ -from django.test import TestCase from django.core.exceptions import ValidationError +from django.test import TestCase -from mapbox_location_field.models import parse_location, LocationField, AddressAutoHiddenField -from mapbox_location_field.forms import LocationField as FormLocationField from mapbox_location_field.forms import AddressAutoHiddenField as FormAddressAutoHiddenField +from mapbox_location_field.forms import LocationField as FormLocationField +from mapbox_location_field.forms import SpatialLocationField as FormSpatialLocationField +from mapbox_location_field.models import parse_location, LocationField, AddressAutoHiddenField, SpatialLocationField class LocationFieldTests(TestCase): @@ -47,10 +48,24 @@ def test_get_prep_value(self): def test_form_field(self): instance = LocationField() - self.assertEqual(instance.formfield().__class__, FormLocationField().__class__) + self.assertTrue(isinstance(instance.formfield(), FormLocationField)) + + +class SpatialLocationFieldTests(TestCase): + + def test_SpatialLocationField(self): + instance = SpatialLocationField() + self.assertIsInstance(instance, SpatialLocationField) + name, path, args, kwargs = instance.deconstruct() + new_instance = SpatialLocationField(*args, **kwargs) + self.assertEqual(instance.map_attrs, new_instance.map_attrs) + + def test_form_field(self): + instance = SpatialLocationField() + self.assertTrue(isinstance(instance.formfield(), FormSpatialLocationField)) class AddressAutoHiddenFieldTests(TestCase): def test_form_field(self): instance = AddressAutoHiddenField() - self.assertEqual(instance.formfield().__class__, FormAddressAutoHiddenField().__class__) + self.assertTrue(isinstance(instance.formfield(), FormAddressAutoHiddenField)) diff --git a/mapbox_location_field/tests/test_widgets.py b/mapbox_location_field/tests/test_widgets.py index cf14753..334c2c9 100644 --- a/mapbox_location_field/tests/test_widgets.py +++ b/mapbox_location_field/tests/test_widgets.py @@ -164,6 +164,11 @@ def test_parse_tuple_string(self): self.assertEqual(parse_tuple_string("(123456,155413.452)"), (123456, 155413.452)) self.assertEqual(parse_tuple_string("(123.456,155413.452)"), (123.456, 155413.452)) + self.assertEqual(parse_tuple_string("SRID=4376POINT (123456 155413)"), (123456, 155413)) + self.assertEqual(parse_tuple_string("SRID=4376POINT (123456.864534 155413452)"), (123456.864534, 155413452)) + self.assertEqual(parse_tuple_string("SRID=4376POINT (123456 155413.452)"), (123456, 155413.452)) + self.assertEqual(parse_tuple_string("SRID=4376POINT (123.456 155413.452)"), (123.456, 155413.452)) + def test_setting_center_point(self): widget = MapInput() widget.get_context("name", (1234.3, 2352145.6), {}) diff --git a/mapbox_location_field/widgets.py b/mapbox_location_field/widgets.py index 205cb83..866c9b5 100644 --- a/mapbox_location_field/widgets.py +++ b/mapbox_location_field/widgets.py @@ -1,8 +1,14 @@ -from django.forms.widgets import TextInput from django.conf import settings +from django.forms.widgets import TextInput def parse_tuple_string(tuple_string): + if "POINT" in tuple_string: + list = tuple_string.split(" ")[1:] + list[0] = list[0][1:] + list[1] = list[1][:-1] + return tuple(map(float, list)) + return tuple(map(float, tuple_string[1:-1].split(","))) diff --git a/testapp/settings.py b/testapp/settings.py index 56b1638..4720929 100644 --- a/testapp/settings.py +++ b/testapp/settings.py @@ -30,6 +30,7 @@ INSTALLED_APPS = [ 'django.contrib.admin', + 'django.contrib.gis', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', @@ -71,12 +72,17 @@ # Database # https://docs.djangoproject.com/en/2.2/ref/settings/#databases +POSTGIS_VERSION = (2, 4, 0) DATABASES = { 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), - } + 'ENGINE': 'django.contrib.gis.db.backends.postgis', + 'NAME': 'travis', + 'USER': 'testuser', + 'PASSWORD': 'password', + 'HOST': 'localhost', + 'PORT': os.getenv('PGPORT'), + }, } # Password validation