From 16b394617a2e1de23e2192dc0515d35fa647daf5 Mon Sep 17 00:00:00 2001 From: Alexander Lindgren Date: Sat, 30 Dec 2023 14:44:29 +0100 Subject: [PATCH 01/15] add restframework to project dependencies --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) 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] From 8a8646697d48f6b40d84bfa18a7f63df779959c2 Mon Sep 17 00:00:00 2001 From: Alexander Lindgren Date: Sat, 30 Dec 2023 14:44:43 +0100 Subject: [PATCH 02/15] add serializer field --- places/serializers.py | 51 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 places/serializers.py diff --git a/places/serializers.py b/places/serializers.py new file mode 100644 index 0000000..e1a167f --- /dev/null +++ b/places/serializers.py @@ -0,0 +1,51 @@ +from rest_framework import serializers + + +class PlacesSerializerField(serializers.Field): + """ + Custom serializer field for handling a Places object represented as a string. + The expected format is "country,city,latitude,longitude". + """ + + def to_representation(self, value): + """ + Converts the Places object to a dictionary for serialization. + """ + if not value: + return None + + parts = [part.strip() for part in str(value).split(',')] + if len(parts) < 4: + raise serializers.ValidationError("Invalid input format for Places") + + return { + 'country': parts[0], + 'city': parts[1], + 'latitude': parts[2], + 'longitude': parts[3] + } + + def to_internal_value(self, data): + """ + Parses the incoming data and converts it into a format suitable for the Places object. + """ + if not isinstance(data, str): + raise serializers.ValidationError("Expected a string input for Places") + + parts = [part.strip() for part in data.split(',')] + if len(parts) != 4: + raise serializers.ValidationError("Expected format: country,city,latitude,longitude") + + try: + country, city, latitude, longitude = parts + latitude = float(latitude) + longitude = float(longitude) + except ValueError: + raise serializers.ValidationError("Latitude and longitude must be valid numbers") + + return { + 'country': country, + 'city': city, + 'latitude': latitude, + 'longitude': longitude + } From 86ca9600e2b7489a52e48a07eb4a5f1ddbd8133c Mon Sep 17 00:00:00 2001 From: Alexander Lindgren Date: Sat, 30 Dec 2023 14:45:05 +0100 Subject: [PATCH 03/15] updated readme --- README.md | 47 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index bf4b403..cd36ef4 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,17 @@ 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() +``` + ## Demo ------ @@ -86,6 +108,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 ------------ From dbb0df9cdce7607f2f516a7888c7df13113ad570 Mon Sep 17 00:00:00 2001 From: Alexander Lindgren Date: Sat, 30 Dec 2023 14:50:10 +0100 Subject: [PATCH 04/15] added tests --- tests/test_serializers.py | 57 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 tests/test_serializers.py diff --git a/tests/test_serializers.py b/tests/test_serializers.py new file mode 100644 index 0000000..20ac0bb --- /dev/null +++ b/tests/test_serializers.py @@ -0,0 +1,57 @@ +from django.test import TestCase + +from rest_framework import serializers + +from places.serializers import PlacesSerializerField + + + +class PlaceSerializerFieldRepresentation(TestCase): + + def setUp(self): + self.field = PlacesSerializerField() + + def test_valid_representation(self): + valid_input = "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_input), expected_output) + + def test_invalid_representation(self): + invalid_input = "Incomplete data" + with self.assertRaises(serializers.ValidationError): + self.field.to_representation(invalid_input) + + 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 = "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_internal_value(input_data), expected_output) + + def test_invalid_input(self): + invalid_data = "Incomplete data" + 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) \ No newline at end of file From 14e4b71243cf3b288f386e2e8b5e5716d0242bda Mon Sep 17 00:00:00 2001 From: Alexander Lindgren Date: Sat, 30 Dec 2023 15:20:37 +0100 Subject: [PATCH 05/15] add more robust validation for serialization --- places/serializers.py | 63 ++++++++++++++++++++++++------------------- 1 file changed, 36 insertions(+), 27 deletions(-) diff --git a/places/serializers.py b/places/serializers.py index e1a167f..20f336d 100644 --- a/places/serializers.py +++ b/places/serializers.py @@ -1,51 +1,60 @@ +from decimal import Decimal, InvalidOperation + from rest_framework import serializers +from . import Places class PlacesSerializerField(serializers.Field): """ - Custom serializer field for handling a Places object represented as a string. - The expected format is "country,city,latitude,longitude". + 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, value): """ Converts the Places object to a dictionary for serialization. """ - if not value: + if not value or not isinstance(value, Places): return None - parts = [part.strip() for part in str(value).split(',')] - if len(parts) < 4: - raise serializers.ValidationError("Invalid input format for Places") + place_parts = value.place.split(', ') + country = place_parts[0] if len(place_parts) > 1 else '' + city = place_parts[1] if len(place_parts) > 1 else place_parts[0] return { - 'country': parts[0], - 'city': parts[1], - 'latitude': parts[2], - 'longitude': parts[3] + 'country': country, + 'city': city, + 'latitude': str(value.latitude), + 'longitude': str(value.longitude) } def to_internal_value(self, data): """ - Parses the incoming data and converts it into a format suitable for the Places object. + Parses the incoming data and converts it into a Places object. """ - if not isinstance(data, str): - raise serializers.ValidationError("Expected a string input for Places") + if not isinstance(data, dict): + raise serializers.ValidationError("Expected a dictionary input for Places") - parts = [part.strip() for part in data.split(',')] - if len(parts) != 4: - raise serializers.ValidationError("Expected format: country,city,latitude,longitude") + country = data.get('country') + if not isinstance(country, str) or not country: + raise serializers.ValidationError({"country": "Country must be a non-empty string"}) + city = data.get('city') + if not isinstance(city, str) or not city: + raise serializers.ValidationError({"city": "City must be a non-empty string"}) + + latitude = data.get('latitude') try: - country, city, latitude, longitude = parts - latitude = float(latitude) - longitude = float(longitude) - except ValueError: - raise serializers.ValidationError("Latitude and longitude must be valid numbers") + latitude = Decimal(latitude) + except (ValueError, TypeError, InvalidOperation): + raise serializers.ValidationError({"latitude": "Latitude must be a valid number"}) - return { - 'country': country, - 'city': city, - 'latitude': latitude, - 'longitude': longitude - } + longitude = data.get('longitude') + try: + longitude = Decimal(longitude) + except (ValueError, TypeError, InvalidOperation): + raise serializers.ValidationError({"longitude": "Longitude must be a valid number"}) + + place = f"{country}, {city}" + return Places(place, latitude, longitude) From c89c5f710cffd45f14101d537b5d8f009198efbc Mon Sep 17 00:00:00 2001 From: Alexander Lindgren Date: Sat, 30 Dec 2023 15:23:48 +0100 Subject: [PATCH 06/15] added more robust teseting testing validators --- tests/test_serializers.py | 47 ++++++++++++++++++++++----------------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/tests/test_serializers.py b/tests/test_serializers.py index 20ac0bb..b03b4a5 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -1,57 +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_input = "USA,New York,40.7128,-74.0060" + 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_input), expected_output) + self.assertEqual(self.field.to_representation(valid_places), expected_output) def test_invalid_representation(self): - invalid_input = "Incomplete data" - with self.assertRaises(serializers.ValidationError): - self.field.to_representation(invalid_input) + 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 = "USA,New York,40.7128,-74.0060" - expected_output = { + input_data = { 'country': 'USA', 'city': 'New York', - 'latitude': 40.7128, - 'longitude': -74.0060 + 'latitude': '40.7128', + 'longitude': '-74.0060' } - self.assertEqual(self.field.to_internal_value(input_data), expected_output) + 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(self): - invalid_data = "Incomplete 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) \ No newline at end of file + self.field.to_internal_value(null_input) From f68ce488c30e0fd9efcdd74e8e1c85259d9fc69d Mon Sep 17 00:00:00 2001 From: Alexander Lindgren Date: Sat, 30 Dec 2023 15:25:09 +0100 Subject: [PATCH 07/15] add JSON example in README --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index cd36ef4..36414e0 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,17 @@ 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 ------ From 6c3d59ae03d4116709593fa7ad15e8360e4f63ae Mon Sep 17 00:00:00 2001 From: Alexander Lindgren Date: Sun, 7 Jan 2024 16:11:38 +0100 Subject: [PATCH 08/15] fix bug wrong order of counry and city --- places/serializers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/places/serializers.py b/places/serializers.py index 20f336d..b002731 100644 --- a/places/serializers.py +++ b/places/serializers.py @@ -19,8 +19,8 @@ def to_representation(self, value): return None place_parts = value.place.split(', ') - country = place_parts[0] if len(place_parts) > 1 else '' - city = place_parts[1] if len(place_parts) > 1 else place_parts[0] + country = place_parts[1] if len(place_parts) > 1 else place_parts[0] + city = place_parts[0] if len(place_parts) > 1 else '' return { 'country': country, From 177589128963e53a9470fbea917c9e6a791359b3 Mon Sep 17 00:00:00 2001 From: Alexander Lindgren Date: Tue, 30 Jan 2024 12:56:46 +0100 Subject: [PATCH 09/15] added field "name" as in name of establishment --- places/__init__.py | 5 +++-- places/fields.py | 29 +++++++++++++---------------- places/serializers.py | 11 ++++++----- 3 files changed, 22 insertions(+), 23 deletions(-) diff --git a/places/__init__.py b/places/__init__.py index 943aa28..dbe131a 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): if isinstance(latitude, float) or isinstance(latitude, int): latitude = str(latitude) @@ -16,11 +16,12 @@ def __init__(self, place, latitude, longitude): longitude = str(longitude) self.place = place + self.name = name 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" % (self.place, self.latitude, self.longitude, self.name) def __repr__(self): return "Places(%s)" % str(self) diff --git a/places/fields.py b/places/fields.py index 832ebee..63f2027 100644 --- a/places/fields.py +++ b/places/fields.py @@ -31,27 +31,24 @@ def to_python(self, value): 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:]] + # Split the value into parts and strip spaces + value_parts = [val.strip() for val in value.split(',')] + # Extract latitude and longitude try: - latitude = value_parts[0] - except IndexError: - latitude = '0.0' + latitude = Decimal(value_parts[-3]) + longitude = Decimal(value_parts[-2]) + except (IndexError, ValueError, decimal.InvalidOperation): + # Default values in case of error + latitude = Decimal('0.0') + longitude = Decimal('0.0') - try: - longitude = value_parts[1] - except IndexError: - longitude = '0.0' + # Extract place and name + place = ','.join(value_parts[:-3]) if len(value_parts) > 3 else None + name = value_parts[-1] if len(value_parts) > 3 else None - try: - place = ','.join(value.split(',')[:-2]) - except: - pass + return Places(place, latitude, longitude, name) - return Places(place, latitude, longitude) def from_db_value(self, value, expression, connection): return self.to_python(value) diff --git a/places/serializers.py b/places/serializers.py index b002731..be903bb 100644 --- a/places/serializers.py +++ b/places/serializers.py @@ -1,7 +1,5 @@ -from decimal import Decimal, InvalidOperation - from rest_framework import serializers - +from decimal import Decimal, InvalidOperation from . import Places class PlacesSerializerField(serializers.Field): @@ -26,7 +24,8 @@ def to_representation(self, value): 'country': country, 'city': city, 'latitude': str(value.latitude), - 'longitude': str(value.longitude) + 'longitude': str(value.longitude), + 'name': value.name } def to_internal_value(self, data): @@ -55,6 +54,8 @@ def to_internal_value(self, data): longitude = Decimal(longitude) except (ValueError, TypeError, InvalidOperation): raise serializers.ValidationError({"longitude": "Longitude must be a valid number"}) + + name = data.get('name') place = f"{country}, {city}" - return Places(place, latitude, longitude) + return Places(place, latitude, longitude, name) From a7175212db5bd31803ef4c8d8c6a34788649dc24 Mon Sep 17 00:00:00 2001 From: Alexander Lindgren Date: Tue, 30 Jan 2024 13:04:46 +0100 Subject: [PATCH 10/15] added formatted address field --- places/__init__.py | 5 +++-- places/fields.py | 1 + places/serializers.py | 6 ++++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/places/__init__.py b/places/__init__.py index dbe131a..0e05cd0 100644 --- a/places/__init__.py +++ b/places/__init__.py @@ -8,7 +8,7 @@ class Places(object): - def __init__(self, place, latitude, longitude, name=None): + def __init__(self, place, latitude, longitude, name=None, formatted_address=None): if isinstance(latitude, float) or isinstance(latitude, int): latitude = str(latitude) @@ -17,11 +17,12 @@ def __init__(self, place, latitude, longitude, name=None): 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, %s" % (self.place, self.latitude, self.longitude, self.name) + 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 63f2027..996c21f 100644 --- a/places/fields.py +++ b/places/fields.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals from decimal import Decimal +import decimal from django.db import models from django.utils.translation import gettext_lazy as _ diff --git a/places/serializers.py b/places/serializers.py index be903bb..fa673e0 100644 --- a/places/serializers.py +++ b/places/serializers.py @@ -25,7 +25,8 @@ def to_representation(self, value): 'city': city, 'latitude': str(value.latitude), 'longitude': str(value.longitude), - 'name': value.name + 'name': value.name, + 'formatted_address': value.formatted_address, } def to_internal_value(self, data): @@ -56,6 +57,7 @@ def to_internal_value(self, data): raise serializers.ValidationError({"longitude": "Longitude must be a valid number"}) name = data.get('name') + formatted_address = data.get('formatted_address') place = f"{country}, {city}" - return Places(place, latitude, longitude, name) + return Places(place, latitude, longitude, name, formatted_address) From 89648e0027fe9c8ea2efaa2cdc0144c30a03b498 Mon Sep 17 00:00:00 2001 From: Alexander Lindgren Date: Tue, 30 Jan 2024 13:09:45 +0100 Subject: [PATCH 11/15] handling when not value place --- places/serializers.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/places/serializers.py b/places/serializers.py index fa673e0..860be02 100644 --- a/places/serializers.py +++ b/places/serializers.py @@ -16,6 +16,9 @@ def to_representation(self, value): if not value or not isinstance(value, Places): return None + if not value.place: + return None + place_parts = value.place.split(', ') country = place_parts[1] if len(place_parts) > 1 else place_parts[0] city = place_parts[0] if len(place_parts) > 1 else '' From 85f2dc4cf789de2de1263dd1e6da19590aed0596 Mon Sep 17 00:00:00 2001 From: Alexander Lindgren Date: Tue, 30 Jan 2024 13:26:00 +0100 Subject: [PATCH 12/15] correctly handling name and formatted address fields --- places/fields.py | 28 +++++++++++++++------------- places/serializers.py | 3 ++- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/places/fields.py b/places/fields.py index 996c21f..62d9ce4 100644 --- a/places/fields.py +++ b/places/fields.py @@ -24,7 +24,7 @@ def __init__(self, *args, **kwargs): def get_internal_type(self): return 'CharField' - + def to_python(self, value): if not value or value == 'None' or value == '': return None @@ -35,20 +35,22 @@ def to_python(self, value): # Split the value into parts and strip spaces value_parts = [val.strip() for val in value.split(',')] - # Extract latitude and longitude - try: - latitude = Decimal(value_parts[-3]) - longitude = Decimal(value_parts[-2]) - except (IndexError, ValueError, decimal.InvalidOperation): - # Default values in case of error - latitude = Decimal('0.0') - longitude = Decimal('0.0') + # Initialize defaults + place, latitude, longitude, name, formatted_address = (None, Decimal('0.0'), Decimal('0.0'), None, None) + + # Assign values based on the expected format + if len(value_parts) >= 7: + place = ','.join(value_parts[:2]) # Country and City + try: + latitude = Decimal(value_parts[2]) + longitude = Decimal(value_parts[3]) + except (ValueError, decimal.InvalidOperation): + pass # Keep default values if conversion fails - # Extract place and name - place = ','.join(value_parts[:-3]) if len(value_parts) > 3 else None - name = value_parts[-1] if len(value_parts) > 3 else None + name = value_parts[4] + formatted_address = ', '.join(value_parts[5:]) # Address Line 1 and Line 2 - return Places(place, latitude, longitude, name) + return Places(place, latitude, longitude, name, formatted_address) def from_db_value(self, value, expression, connection): diff --git a/places/serializers.py b/places/serializers.py index 860be02..993a0be 100644 --- a/places/serializers.py +++ b/places/serializers.py @@ -19,7 +19,7 @@ def to_representation(self, value): if not value.place: return None - place_parts = value.place.split(', ') + place_parts = value.place.split(',') country = place_parts[1] if len(place_parts) > 1 else place_parts[0] city = place_parts[0] if len(place_parts) > 1 else '' @@ -63,4 +63,5 @@ def to_internal_value(self, data): formatted_address = data.get('formatted_address') place = f"{country}, {city}" + return Places(place, latitude, longitude, name, formatted_address) From a5c9a98c3311c72b4c2d288235ae2c188b33c8f0 Mon Sep 17 00:00:00 2001 From: Alexander Lindgren Date: Tue, 30 Jan 2024 21:04:23 +0100 Subject: [PATCH 13/15] update to use JSON field --- places/fields.py | 51 +++++++++++++++++------------------- places/forms.py | 4 +++ places/serializers.py | 60 ++++--------------------------------------- places/widgets.py | 4 ++- 4 files changed, 36 insertions(+), 83 deletions(-) diff --git a/places/fields.py b/places/fields.py index 62d9ce4..1cbd3ec 100644 --- a/places/fields.py +++ b/places/fields.py @@ -1,11 +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: @@ -15,49 +19,42 @@ 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 - # Split the value into parts and strip spaces - value_parts = [val.strip() for val in value.split(',')] - - # Initialize defaults - place, latitude, longitude, name, formatted_address = (None, Decimal('0.0'), Decimal('0.0'), None, None) - - # Assign values based on the expected format - if len(value_parts) >= 7: - place = ','.join(value_parts[:2]) # Country and City - try: - latitude = Decimal(value_parts[2]) - longitude = Decimal(value_parts[3]) - except (ValueError, decimal.InvalidOperation): - pass # Keep default values if conversion fails + if value is None or isinstance(value, dict): + return value - name = value_parts[4] - formatted_address = ', '.join(value_parts[5:]) # Address Line 1 and Line 2 + # Assuming the value is a string representation of a dict + # Convert it to a dict and then to a Places object + try: + 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 - return Places(place, latitude, longitude, name, formatted_address) + def get_prep_value(self, value): + if isinstance(value, Places): + return value.to_dict() + # If the value is already a dict or None, just use it as-is + 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..0e55b55 100644 --- a/places/forms.py +++ b/places/forms.py @@ -11,6 +11,10 @@ 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')), diff --git a/places/serializers.py b/places/serializers.py index 993a0be..7959bc5 100644 --- a/places/serializers.py +++ b/places/serializers.py @@ -9,59 +9,9 @@ class PlacesSerializerField(serializers.Field): For serialization, it converts the Places object to this dictionary format. """ - def to_representation(self, value): - """ - Converts the Places object to a dictionary for serialization. - """ - if not value or not isinstance(value, Places): - return None - - if not value.place: - return None - - place_parts = value.place.split(',') - country = place_parts[1] if len(place_parts) > 1 else place_parts[0] - city = place_parts[0] if len(place_parts) > 1 else '' - - return { - 'country': country, - 'city': city, - 'latitude': str(value.latitude), - 'longitude': str(value.longitude), - 'name': value.name, - 'formatted_address': value.formatted_address, - } - + def to_representation(self, obj): + return obj.to_dict() if obj else None + def to_internal_value(self, data): - """ - Parses the incoming data and converts it into a Places object. - """ - if not isinstance(data, dict): - raise serializers.ValidationError("Expected a dictionary input for Places") - - country = data.get('country') - if not isinstance(country, str) or not country: - raise serializers.ValidationError({"country": "Country must be a non-empty string"}) - - city = data.get('city') - if not isinstance(city, str) or not city: - raise serializers.ValidationError({"city": "City must be a non-empty string"}) - - latitude = data.get('latitude') - try: - latitude = Decimal(latitude) - except (ValueError, TypeError, InvalidOperation): - raise serializers.ValidationError({"latitude": "Latitude must be a valid number"}) - - longitude = data.get('longitude') - try: - longitude = Decimal(longitude) - except (ValueError, TypeError, InvalidOperation): - raise serializers.ValidationError({"longitude": "Longitude must be a valid number"}) - - name = data.get('name') - formatted_address = data.get('formatted_address') - - place = f"{country}, {city}" - - return Places(place, latitude, longitude, name, formatted_address) + place_from_dict = Places.from_dict(data) + return place_from_dict diff --git a/places/widgets.py b/places/widgets.py index 708b843..601bb82 100755 --- a/places/widgets.py +++ b/places/widgets.py @@ -36,7 +36,9 @@ def decompress(self, value): if isinstance(value, str): return value.rsplit(',') if value: - return [value.place, value.latitude, value.longitude] + print('value from decompress', value) + place = f'{value.country}, {value.city}, {value.formatted_address}' + return [place, value.latitude, value.longitude] return [None, None] def get_context(self, name, value, attrs): From 537ca9c060dd3ce4d62e7cb8f5da40e536e5d861 Mon Sep 17 00:00:00 2001 From: Alexander Lindgren Date: Tue, 30 Jan 2024 22:06:37 +0100 Subject: [PATCH 14/15] working on getting django admin to work --- places/fields.py | 5 +++++ places/forms.py | 15 +++++++++++++++ places/static/places/places.css | 4 ++-- places/static/places/places.js | 34 +++++++++++++++++++++++++++++++-- places/widgets.py | 9 +++++++++ 5 files changed, 63 insertions(+), 4 deletions(-) diff --git a/places/fields.py b/places/fields.py index 1cbd3ec..dc5e921 100644 --- a/places/fields.py +++ b/places/fields.py @@ -26,8 +26,13 @@ def __init__(self, *args, **kwargs): super(PlacesField, self).__init__(*args, **kwargs) def to_python(self, value): + print('value from to_python', value) if isinstance(value, Places): return value + + if isinstance(value, list): + if len(value) == 3: + print('value', value) if value is None or isinstance(value, dict): return value diff --git a/places/forms.py b/places/forms.py index 0e55b55..6fdd53d 100644 --- a/places/forms.py +++ b/places/forms.py @@ -11,6 +11,7 @@ class PlacesField(forms.MultiValueField): default_error_messages = {'invalid': _('Enter a valid geoposition.')} def __init__(self, *args, **kwargs): + print("Initializing PlacesField with args:", args, "kwargs:", kwargs) kwargs.pop('encoder', None) kwargs.pop('decoder', None) @@ -19,6 +20,11 @@ def __init__(self, *args, **kwargs): 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(',')) @@ -31,6 +37,15 @@ def widget_attrs(self, widget): return {'class': ' '.join(classes)} def compress(self, value_list): + print("Compressing values in PlacesField:", value_list) if value_list: return value_list return "" + + def prepare_value(self, value): + print("Preparing value in PlacesField:", value) + if isinstance(value, Places): + return value.to_dict() + return value + + 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 601bb82..c27c2ba 100755 --- a/places/widgets.py +++ b/places/widgets.py @@ -11,6 +11,7 @@ class PlacesWidget(widgets.MultiWidget): template_name = 'places/widgets/places.html' def __init__(self, attrs=None): + print("Initializing PlacesWidget") _widgets = ( widgets.TextInput( attrs={'data-geo': 'formatted_address', 'data-id': 'map_place'} @@ -29,10 +30,17 @@ 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': 'City', 'id': 'id_location_state'}), + ) super(PlacesWidget, self).__init__(_widgets, attrs) def decompress(self, value): + print("Decompressing value in PlacesWidget:", value) if isinstance(value, str): return value.rsplit(',') if value: @@ -43,6 +51,7 @@ def decompress(self, value): def get_context(self, name, value, attrs): context = super(PlacesWidget, self).get_context(name, value, attrs) + print("Context in PlacesWidget for", name, ":", context) context['map_widget_height'] = settings.MAP_WIDGET_HEIGHT context['map_options'] = settings.MAP_OPTIONS context['marker_options'] = settings.MARKER_OPTIONS From c48cf7f0d4b4dfc2f6712ce8ff4bf88b06d38728 Mon Sep 17 00:00:00 2001 From: Alexander Lindgren Date: Wed, 31 Jan 2024 08:47:19 +0100 Subject: [PATCH 15/15] update now working trough both django admin and API --- places/fields.py | 29 +++++++++++++++++++++++------ places/forms.py | 19 ++++++++++++++----- places/serializers.py | 7 ++++++- places/widgets.py | 13 ++++++------- 4 files changed, 49 insertions(+), 19 deletions(-) diff --git a/places/fields.py b/places/fields.py index dc5e921..c47819d 100644 --- a/places/fields.py +++ b/places/fields.py @@ -26,19 +26,33 @@ def __init__(self, *args, **kwargs): super(PlacesField, self).__init__(*args, **kwargs) def to_python(self, value): - print('value from to_python', value) if isinstance(value, Places): return value - + + # 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 + if isinstance(value, list): - if len(value) == 3: - print('value', value) + # 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 - # Assuming the value is a string representation of a dict - # Convert it to a dict and then to a Places object + # Handle string representation of a dict try: value_dict = json.loads(value) return Places.from_dict(value_dict) @@ -52,6 +66,9 @@ def get_prep_value(self, value): # 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: diff --git a/places/forms.py b/places/forms.py index 6fdd53d..524a495 100644 --- a/places/forms.py +++ b/places/forms.py @@ -11,8 +11,6 @@ class PlacesField(forms.MultiValueField): default_error_messages = {'invalid': _('Enter a valid geoposition.')} def __init__(self, *args, **kwargs): - print("Initializing PlacesField with args:", args, "kwargs:", kwargs) - kwargs.pop('encoder', None) kwargs.pop('decoder', None) @@ -37,13 +35,24 @@ def widget_attrs(self, widget): return {'class': ' '.join(classes)} def compress(self, value_list): - print("Compressing values in PlacesField:", 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): - print("Preparing value in PlacesField:", value) if isinstance(value, Places): return value.to_dict() return value diff --git a/places/serializers.py b/places/serializers.py index 7959bc5..4947cce 100644 --- a/places/serializers.py +++ b/places/serializers.py @@ -10,7 +10,12 @@ class PlacesSerializerField(serializers.Field): """ def to_representation(self, obj): - return obj.to_dict() if obj else None + 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) diff --git a/places/widgets.py b/places/widgets.py index c27c2ba..e25a1ae 100755 --- a/places/widgets.py +++ b/places/widgets.py @@ -1,17 +1,16 @@ # -*- 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): template_name = 'places/widgets/places.html' def __init__(self, attrs=None): - print("Initializing PlacesWidget") _widgets = ( widgets.TextInput( attrs={'data-geo': 'formatted_address', 'data-id': 'map_place'} @@ -34,24 +33,24 @@ def __init__(self, attrs=None): 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': 'City', 'id': 'id_location_state'}), + widgets.TextInput(attrs={'placeholder': 'State', 'id': 'id_location_state'}), ) super(PlacesWidget, self).__init__(_widgets, attrs) def decompress(self, value): - print("Decompressing value in PlacesWidget:", value) if isinstance(value, str): return value.rsplit(',') if value: + 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] + 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): context = super(PlacesWidget, self).get_context(name, value, attrs) - print("Context in PlacesWidget for", name, ":", context) context['map_widget_height'] = settings.MAP_WIDGET_HEIGHT context['map_options'] = settings.MAP_OPTIONS context['marker_options'] = settings.MARKER_OPTIONS