diff --git a/README.md b/README.md index bf4b403..36414e0 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,11 @@ A Django app for store places with autocomplete function and a related map to th Install `dj-places` and add it to your installed apps: -``` +```bash $ pip install dj-places ``` -``` +```py INSTALLED_APPS = ( ... 'places', @@ -21,9 +21,20 @@ $ pip install dj-places ) ``` +Add djangorestframework to your installed apps (required for the package as it provide a serializer field): + +```py + INSTALLED_APPS = ( + ... + 'rest_framework', + ... + ) +``` + + Add the following settings and maps api key ([read more here](https://developers.google.com/maps/documentation/javascript/reference/map)): -```python +```bash PLACES_MAPS_API_KEY='YourAwesomeUltraSecretKey' PLACES_MAP_WIDGET_HEIGHT=480 PLACES_MAP_OPTIONS='{"center": { "lat": 38.971584, "lng": -95.235072 }, "zoom": 10}' @@ -35,7 +46,7 @@ PLACES_MARKER_OPTIONS='{"draggable": true}' Then use it in a project: -```python +```py from django.db import models from places.fields import PlacesField @@ -47,16 +58,16 @@ class MyLocationModel(models.Model): This enables the following API: -```python - >>> from myapp.models import ModelName - >>> poi = ModelName.objects.get(id=1) - >>> poi.position +```bash + >>> from myapp.models import MyLocationModel + >>> poi = MyLocationModel.objects.get(id=1) + >>> poi.location Place('Metrocentro, Managua, Nicaragua', 52.522906, 13.41156) - >>> poi.position.place + >>> poi.location.place 'Metrocentro, Managua, Nicaragua' - >>> poi.position.latitude + >>> poi.location.latitude 52.522906 - >>> poi.position.longitude + >>> poi.location.longitude 13.41156 ``` @@ -72,6 +83,28 @@ For using outside the Django Admin: ``` Remember to add the `{{ form.media }}` in your template. + +For usage in Djangorestframework Serializers: + +```py +from places.serializers import PlacesSerializerField +from rest_framework import serializers + +class MyLocationModelSerializer(serializers.Serializer): + location = PlaceSerializerField() +``` + +How the location data is displayed when doing a GET request in JSON with a serializer that has the field included, and is also how the data should be provided when doing a PUT/PATCH/POST: + +```json +"location": { + "city": "Stockholm", + "country": "Sverige", + "latitude": "59.32932349999999", + "longitude": "18.0685808" +} +``` + ## Demo ------ @@ -86,6 +119,9 @@ Tools used in rendering this package: * [cookiecutter-djangopackage](https://github.com/pydanny/cookiecutter-djangopackage) * [jquery-geocomplete](https://github.com/ubilabs/geocomplete) (_no longer used in the project._) +Contributors +[minifisk](https://github.com/minifisk) - Adding serializer field + ### Similar Projects ------------ diff --git a/places/__init__.py b/places/__init__.py index 943aa28..0e05cd0 100644 --- a/places/__init__.py +++ b/places/__init__.py @@ -8,7 +8,7 @@ class Places(object): - def __init__(self, place, latitude, longitude): + def __init__(self, place, latitude, longitude, name=None, formatted_address=None): if isinstance(latitude, float) or isinstance(latitude, int): latitude = str(latitude) @@ -16,11 +16,13 @@ def __init__(self, place, latitude, longitude): longitude = str(longitude) self.place = place + self.name = name + self.formatted_address = formatted_address self.latitude = Decimal(latitude) self.longitude = Decimal(longitude) def __str__(self): - return "%s, %s, %s" % (self.place, self.latitude, self.longitude) + return "%s, %s, %s, %s, %s" % (self.place, self.latitude, self.longitude, self.name, self.formatted_address) def __repr__(self): return "Places(%s)" % str(self) diff --git a/places/fields.py b/places/fields.py index 832ebee..c47819d 100644 --- a/places/fields.py +++ b/places/fields.py @@ -1,10 +1,15 @@ + # -*- coding: utf-8 -*- from __future__ import unicode_literals +import json from decimal import Decimal +import decimal from django.db import models from django.utils.translation import gettext_lazy as _ +from django.db.models import JSONField + try: from django.utils.encoding import smart_text except ImportError: @@ -14,50 +19,64 @@ from .forms import PlacesField as PlacesFormField -class PlacesField(models.Field): +class PlacesField(JSONField): description = _('A geoposition field (latitude and longitude)') def __init__(self, *args, **kwargs): - kwargs['max_length'] = 255 super(PlacesField, self).__init__(*args, **kwargs) - def get_internal_type(self): - return 'CharField' - def to_python(self, value): - if not value or value == 'None' or value == '': - return None - if isinstance(value, Places): return value - if isinstance(value, list): - return Places(value[0], value[1], value[2]) - - value_parts = [Decimal(val) for val in value.split(',')[-2:]] + # Check if value is a string representation of a list + if isinstance(value, str): + try: + value = json.loads(value) + except (ValueError, TypeError): + pass # If it's not a JSON string, proceed with the original value - try: - latitude = value_parts[0] - except IndexError: - latitude = '0.0' + if isinstance(value, list): + # Process list to create a Places object + if len(value) >= 8: + return Places( + country=value[5], + city=value[6], + state=value[7], + latitude=value[1], + longitude=value[2], + name=value[3], + formatted_address=value[4] + ) + + if value is None or isinstance(value, dict): + return value + # Handle string representation of a dict try: - longitude = value_parts[1] - except IndexError: - longitude = '0.0' + value_dict = json.loads(value) + return Places.from_dict(value_dict) + except (ValueError, TypeError): + # In case the string cannot be converted to a dict + return None - try: - place = ','.join(value.split(',')[:-2]) - except: - pass + def get_prep_value(self, value): + if isinstance(value, Places): + return value.to_dict() - return Places(place, latitude, longitude) + # If the value is already a dict or None, just use it as-is + return value + + def clean(self, value, model_instance): + return value def from_db_value(self, value, expression, connection): + if value is None: + return value return self.to_python(value) - def get_prep_value(self, value): - return str(value) + def from_db_value(self, value, expression, connection): + return self.to_python(value) def value_to_string(self, obj): value = self.value_from_object(obj) diff --git a/places/forms.py b/places/forms.py index 3f93173..524a495 100644 --- a/places/forms.py +++ b/places/forms.py @@ -11,10 +11,18 @@ class PlacesField(forms.MultiValueField): default_error_messages = {'invalid': _('Enter a valid geoposition.')} def __init__(self, *args, **kwargs): + kwargs.pop('encoder', None) + kwargs.pop('decoder', None) + fields = ( forms.CharField(label=_('place')), forms.DecimalField(label=_('Latitude')), forms.DecimalField(label=_('Longitude')), + forms.CharField(label=_('Name'), required=False), + forms.CharField(label=_('Formatted Address'), required=False), + forms.CharField(label=_('Country'), required=False), + forms.CharField(label=_('City'), required=False), + forms.CharField(label=_('State'), required=False), ) if 'initial' in kwargs and kwargs['initial'] != '': kwargs['initial'] = Places(*kwargs['initial'].split(',')) @@ -28,5 +36,25 @@ def widget_attrs(self, widget): def compress(self, value_list): if value_list: - return value_list + place = Places( + latitude=value_list[1], + longitude=value_list[2], + name=value_list[3], + formatted_address=value_list[4], + country=value_list[5], + city=value_list[6], + state=value_list[7], + ) + return place return "" + + + def clean(self, value): + return value + + def prepare_value(self, value): + if isinstance(value, Places): + return value.to_dict() + return value + + diff --git a/places/serializers.py b/places/serializers.py new file mode 100644 index 0000000..4947cce --- /dev/null +++ b/places/serializers.py @@ -0,0 +1,22 @@ +from rest_framework import serializers +from decimal import Decimal, InvalidOperation +from . import Places + +class PlacesSerializerField(serializers.Field): + """ + Custom serializer field for handling a Places object. + The expected input format for deserialization is a dictionary with keys 'country', 'city', 'latitude', 'longitude'. + For serialization, it converts the Places object to this dictionary format. + """ + + def to_representation(self, obj): + if isinstance(obj, Places): + return obj.to_dict() + elif isinstance(obj, dict): + return obj + else: + return None + + def to_internal_value(self, data): + place_from_dict = Places.from_dict(data) + return place_from_dict diff --git a/places/static/places/places.css b/places/static/places/places.css index ff4ba25..94673f8 100644 --- a/places/static/places/places.css +++ b/places/static/places/places.css @@ -1,4 +1,4 @@ -.places-widget { +/* .places-widget { overflow: hidden; } .places-widget > input:first-child { @@ -12,4 +12,4 @@ .places-widget > div { border: 1px solid #CACACA; margin-top: 10px; - } + } */ diff --git a/places/static/places/places.js b/places/static/places/places.js index 1329646..9337af5 100755 --- a/places/static/places/places.js +++ b/places/static/places/places.js @@ -1,8 +1,14 @@ function setupDjangoPlaces(mapConfig, markerConfig, childs) { + var searchBox = new google.maps.places.SearchBox(childs[0]); var latInput = childs[1]; var lngInput = childs[2]; - var searchBox = new google.maps.places.SearchBox(childs[0]); - var gmap = new google.maps.Map(childs[3], mapConfig); + var nameInput = childs[3]; + var addressInput = childs[4]; + var countryInput = childs[5]; + var cityInput = childs[6]; + var stateInput = childs[7]; + var gmap = new google.maps.Map(childs[8], mapConfig); + var marker = new google.maps.Marker(markerConfig); if (latInput.value && lngInput.value) { @@ -32,6 +38,30 @@ function setupDjangoPlaces(mapConfig, markerConfig, childs) { }; marker.setPosition(place.geometry.location); marker.setMap(gmap); + console.log('place', place) + + + let country + let city + let state + + place.address_components.forEach(function(component) { + if (component.types.includes("country")) { + country = component.long_name; + } + if (component.types.includes("locality") || component.types.includes("postal_town")) { + city = component.long_name; + } + if (component.types.includes("administrative_area_level_1")) { + state = component.long_name; + } + }); + + nameInput.value = place.name; + addressInput.value = place.formatted_address; + countryInput.value = country; + cityInput.value = city; + stateInput.value = state; latInput.value = place.geometry.location.lat(); lngInput.value = place.geometry.location.lng(); gmap.setCenter(place.geometry.location); diff --git a/places/widgets.py b/places/widgets.py index 708b843..e25a1ae 100755 --- a/places/widgets.py +++ b/places/widgets.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- - from django.forms import widgets from django.template.loader import render_to_string from django.utils.translation import gettext_lazy as _ from .conf import settings +from . import Places class PlacesWidget(widgets.MultiWidget): @@ -29,6 +29,12 @@ def __init__(self, attrs=None): 'placeholder': _('Longitude'), } ), + widgets.TextInput(attrs={'placeholder': 'Name', 'id': 'id_location_name'}), + widgets.TextInput(attrs={'placeholder': 'Formatted Address', 'id': 'id_location_formatted_address'}), + widgets.TextInput(attrs={'placeholder': 'Country', 'id': 'id_location_country'}), + widgets.TextInput(attrs={'placeholder': 'City', 'id': 'id_location_city'}), + widgets.TextInput(attrs={'placeholder': 'State', 'id': 'id_location_state'}), + ) super(PlacesWidget, self).__init__(_widgets, attrs) @@ -36,7 +42,11 @@ def decompress(self, value): if isinstance(value, str): return value.rsplit(',') if value: - return [value.place, value.latitude, value.longitude] + if isinstance(value, Places): + value = value.to_dict() + print('value from decompress', value) + place = f'{value["country"]}, {value["city"]}, {value["formatted_address"]}' + return [place, value["latitude"], value["longitude"], value["name"], value["formatted_address"], value["country"], value["city"], value["state"]] return [None, None] def get_context(self, name, value, attrs): diff --git a/pyproject.toml b/pyproject.toml index e97b9cc..6ed33f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ classifiers = [ ] dependencies = [ "Django>=3.0", + 'djangorestframework>=3.0', ] [project.urls] diff --git a/tests/test_serializers.py b/tests/test_serializers.py new file mode 100644 index 0000000..b03b4a5 --- /dev/null +++ b/tests/test_serializers.py @@ -0,0 +1,64 @@ +from django.test import TestCase +from rest_framework import serializers +from places.serializers import PlacesSerializerField +from places.fields import Places + +class PlaceSerializerFieldRepresentation(TestCase): + def setUp(self): + self.field = PlacesSerializerField() + + def test_valid_representation(self): + valid_places = Places("USA, New York", "40.7128", "-74.0060") + expected_output = { + 'country': 'USA', + 'city': 'New York', + 'latitude': '40.7128', + 'longitude': '-74.0060' + } + self.assertEqual(self.field.to_representation(valid_places), expected_output) + + def test_invalid_representation(self): + invalid_places = "Incomplete data" # This should be an invalid type + result = self.field.to_representation(invalid_places) + self.assertIsNone(result) + + def test_null_representation(self): + null_input = None + self.assertIsNone(self.field.to_representation(null_input)) + +class TestPlaceSerializerFieldToInternalValue(TestCase): + def setUp(self): + self.field = PlacesSerializerField() + + def test_valid_input(self): + input_data = { + 'country': 'USA', + 'city': 'New York', + 'latitude': '40.7128', + 'longitude': '-74.0060' + } + expected_output = Places("USA, New York", 40.7128, -74.0060) + result = self.field.to_internal_value(input_data) + self.assertEqual(result.place, expected_output.place) + self.assertEqual(result.latitude, expected_output.latitude) + self.assertEqual(result.longitude, expected_output.longitude) + + def test_invalid_input_missing_fields(self): + invalid_data = {"country": "USA"} # Missing other fields + with self.assertRaises(serializers.ValidationError): + self.field.to_internal_value(invalid_data) + + def test_invalid_input_wrong_types(self): + invalid_data = { + 'country': 'USA', + 'city': 'New York', + 'latitude': 'not_a_number', + 'longitude': '-74.0060' + } + with self.assertRaises(serializers.ValidationError): + self.field.to_internal_value(invalid_data) + + def test_null_input(self): + null_input = None + with self.assertRaises(serializers.ValidationError): + self.field.to_internal_value(null_input)