diff --git a/CHANGELOG.md b/CHANGELOG.md index 43413525..c3bdade4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## [0.13.1] - 2024-03-02 + - Added: Inline form support + ## [0.13.0] - 2022-03-22 - Added: Django 4.0 support - Added: Python 3.10 support diff --git a/README.rst b/README.rst index 24d7e8f0..7ca415df 100644 --- a/README.rst +++ b/README.rst @@ -88,6 +88,55 @@ USAGE: }, } +USING INLINE FORMS: +=================== +- To use as a model admin inline form, everything is basically the same as above except for the widget + used. It's best to use ``django.contrib.admin.StackedInline`` as opposed to ``django.contrib.admin.TabularInline`` + + .. code:: python + + from django.db import models + from django_google_maps import fields as map_fields + + class Shipment(models.Model): + tracking_id = models.CharField(max_length=255) + carrier = models.CharField(max_length=255) + + class Location(models.Model): + shipment = models.ForeignKey(Shipment) + address = map_fields.AddressField(max_length=200) + geolocation = map_fields.GeoLocationField(max_length=50) + +- in the ``forms.py`` file, define the form and set widget for "address" field: + + .. code:: python + + from django import forms + from django_google_maps import widgets as map_widgets + + class LocationForm(forms.ModelForm): + class Meta: + model = models.Location + widgets = { + "address": map_widgets.GoogleMapsAddressInlineWidget(), + } + +- in the ``admin.py`` file, define a model form and a stacked inline like below: + + .. code:: python + + from django.contrib import admin + + from . import models, forms + + class LocationInline(admin.StackedInline): + model = models.Location + form = forms.LocationForm + + @admin.register(models.Shipment) + class ShipmentAdmin(admin.ModelAdmin): + inlines = [LocationInline] + That should be all you need to get started. I also like to make the geolocation field readonly in the admin so a user @@ -101,4 +150,4 @@ I get around to it I'll see if I can create a method that will build that into the model. .. |Build Status| image:: https://travis-ci.org/madisona/django-google-maps.png - :target: https://travis-ci.org/madisona/django-google-maps \ No newline at end of file + :target: https://travis-ci.org/madisona/django-google-maps diff --git a/django_google_maps/models.py b/django_google_maps/models.py deleted file mode 100644 index e69de29b..00000000 diff --git a/django_google_maps/static/django_google_maps/css/google-maps-admin.css b/django_google_maps/static/django_google_maps/css/google-maps-admin.css index 5ba4d8c7..4055ee8d 100644 --- a/django_google_maps/static/django_google_maps/css/google-maps-admin.css +++ b/django_google_maps/static/django_google_maps/css/google-maps-admin.css @@ -1,11 +1,31 @@ @media (min-width: 848px) { - .map_canvas_wrapper {margin-left: 170px;} - .main.shifted .map_canvas_wrapper {margin-left: 0;} + .map_canvas_wrapper { + margin-left: 170px; + } + + .main.shifted .map_canvas_wrapper { + margin-left: 0; + } } + @media (min-width: 1118px) { - .main.shifted .map_canvas_wrapper {margin-left: 170px;} + .main.shifted .map_canvas_wrapper { + margin-left: 170px; + } +} + +#id_address, +input[id^="id_locations-"][id$="-address"] { + width: 40em; + max-width: 100%; } -#id_address {width: 40em;} -.map_canvas_wrapper {width: 100%;} -#map_canvas {width: 100%; height: 40em;} +.map_canvas_wrapper { + width: 100%; +} + +#map_canvas, +[id^="locations-"][id$="-address_map_canvas"] { + width: 100%; + height: 30em; +} diff --git a/django_google_maps/static/django_google_maps/js/google-maps-admin-inline.js b/django_google_maps/static/django_google_maps/js/google-maps-admin-inline.js new file mode 100644 index 00000000..927bc2da --- /dev/null +++ b/django_google_maps/static/django_google_maps/js/google-maps-admin-inline.js @@ -0,0 +1,160 @@ +/* +This script expects: + + + +*/ + +class LocationFormGoogleMap { + constructor(index) { + this.index = index; + this.autocomplete = null; + this.geocoder = new google.maps.Geocoder(); + this.map = null; + this.marker = null; + this.geolocationId = `id_locations-${index}-geolocation`; + this.addressId = `id_locations-${index}-address`; + this.mapCanvasId = `locations-${index}-address_map_canvas`; + } + + initialize() { + let lat = 0; + let lng = 0; + let zoom = 2; + + // Update zoom level and initial cordinates if form is bound + const existingLocation = this.getExistingLocation(); + if (existingLocation) { + lat = existingLocation[0]; + lng = existingLocation[1]; + zoom = 12; + } + + const latlng = new google.maps.LatLng(lat, lng); + const mapOptions = { + zoom: zoom, + center: latlng, + mapTypeId: this.getMapType(), + }; + this.map = new google.maps.Map(document.getElementById(this.mapCanvasId), mapOptions); + + if (existingLocation) this.setMarker(latlng); + + this.autocomplete = new google.maps.places.Autocomplete( + document.getElementById(this.addressId), + this.getAutoCompleteOptions() + ); + + this.autocomplete.addListener("place_changed", () => this.codeAddress()); + + $(`#${this.addressId}`).keydown(function (e) { + if (e.keyCode == 13) { + e.preventDefault(); + return false; + } + }); + } + + getMapType() { + const geolocationInput = document.getElementById(this.addressId); + const allowedType = ["roadmap", "satellite", "hybrid", "terrain"]; + const mapType = geolocationInput.getAttribute("data-map-type"); + + if (mapType && -1 !== allowedType.indexOf(mapType)) return mapType; + return google.maps.MapTypeId.HYBRID; + } + + getAutoCompleteOptions() { + const geolocationInput = document.getElementById(this.addressId); + const autocompleteOptions = geolocationInput.getAttribute( + "data-autocomplete-options" + ); + + if (!autocompleteOptions) return { types: ["geocode"] }; + return JSON.parse(autocompleteOptions); + } + + getExistingLocation() { + const geolocationInput = document.getElementById(this.geolocationId); + if (geolocationInput && geolocationInput.value) { + return geolocationInput.value.split(","); + } + } + + codeAddress() { + const place = this.autocomplete.getPlace(); + + if (place.geometry !== undefined) { + this.updateWithCoordinates(place.geometry.location); + } else { + this.geocoder.geocode({ address: place.name }, (results, status) => { + if (status == google.maps.GeocoderStatus.OK) { + const latlng = results[0].geometry.location; + this.updateWithCoordinates(latlng); + } else { + alert("Geocode was not successful for the following reason: " + status); + } + }); + } + } + + updateWithCoordinates(latlng) { + this.map.setCenter(latlng); + this.map.setZoom(18); + this.setMarker(latlng); + this.updateGeolocation(latlng); + } + + setMarker(latlng) { + this.marker + ? this.updateMarker(latlng) + : this.addMarker({ latlng: latlng, draggable: true }); + } + + addMarker(Options) { + this.marker = new google.maps.Marker({ + map: this.map, + position: Options.latlng, + }); + + const draggable = Options.draggable || false; + if (draggable) { + this.addMarkerDrag(this.marker); + } + } + + addMarkerDrag(marker) { + marker.setDraggable(true); + google.maps.event.addListener(marker, "dragend", (new_location) => { + this.updateGeolocation(new_location.latLng); + }); + } + + updateMarker(latlng) { + this.marker.setPosition(latlng); + } + + updateGeolocation(latlng) { + document.getElementById(this.geolocationId).value = latlng.lat() + "," + latlng.lng(); + $(`#${this.geolocationId}`).trigger("change"); + } +} + +$(document).ready(function () { + const addressInputSelector = "input[id^='id_locations-'][id$='-address']:visible"; + + // Initialize existing location forms + $(addressInputSelector).each(function (index) { + new LocationFormGoogleMap(index).initialize(); + }); + + // Listen and check for insertion of new location forms + $(document).bind("formset:added", function (e) { + const newAddressInput = $(e.target).find(addressInputSelector); + + if (newAddressInput) { + const index = parseInt(newAddressInput.attr("id").match(/(?<=-)\d+(?=-)/)[0]); + new LocationFormGoogleMap(index).initialize(); + } + }); +}); diff --git a/django_google_maps/templates/django_google_maps/widgets/map_widget_inline.html b/django_google_maps/templates/django_google_maps/widgets/map_widget_inline.html new file mode 100644 index 00000000..f43a1d0a --- /dev/null +++ b/django_google_maps/templates/django_google_maps/widgets/map_widget_inline.html @@ -0,0 +1,4 @@ +{% include "django/forms/widgets/text.html" %} +
+
+
diff --git a/django_google_maps/tests/test_widget.py b/django_google_maps/tests/test_widget.py deleted file mode 100644 index 8e8c9c3f..00000000 --- a/django_google_maps/tests/test_widget.py +++ /dev/null @@ -1,27 +0,0 @@ -from django import test -from django.conf import settings -from django_google_maps.widgets import GoogleMapsAddressWidget - - -class WidgetTests(test.TestCase): - def test_render_returns_xxxxxxx(self): - widget = GoogleMapsAddressWidget() - results = widget.render('name', 'value', attrs={'a1': 1, 'a2': 2}) - expected = '' - expected += '
' - expected += '
' - self.assertHTMLEqual(expected, results) - - def test_render_returns_blank_for_value_when_none(self): - widget = GoogleMapsAddressWidget() - results = widget.render('name', None, attrs={'a1': 1, 'a2': 2}) - expected = '' - expected += '
' - expected += '
' - self.assertHTMLEqual(expected, results) - - def test_maps_js_uses_api_key(self): - widget = GoogleMapsAddressWidget() - google_maps_js = "https://maps.google.com/maps/api/js?key={}&libraries=places".format( - settings.GOOGLE_MAPS_API_KEY) - self.assertEqual(google_maps_js, widget.Media().js[1]) diff --git a/django_google_maps/tests/test_widgets.py b/django_google_maps/tests/test_widgets.py new file mode 100644 index 00000000..4be4c260 --- /dev/null +++ b/django_google_maps/tests/test_widgets.py @@ -0,0 +1,75 @@ +from django import test +from django.conf import settings + +from django_google_maps import widgets + + +class WidgetTests(test.TestCase): + def test_render_returns_with_value(self): + widget = widgets.GoogleMapsAddressWidget() + results = widget.render("name", "value", attrs={"a1": 1, "a2": 2}) + expected = '' + expected += '
' + expected += '
' + self.assertHTMLEqual(expected, results) + + def test_render_returns_blank_for_value_when_none(self): + widget = widgets.GoogleMapsAddressWidget() + results = widget.render("name", None, attrs={"a1": 1, "a2": 2}) + expected = '' + expected += '
' + expected += '
' + self.assertHTMLEqual(expected, results) + + def test_widgets_media_js(self): + widget = widgets.GoogleMapsAddressWidget() + google_maps_js = ( + "https://maps.google.com/maps/api/js?key={}&libraries=places".format( + settings.GOOGLE_MAPS_API_KEY + ) + ) + admin_js = "django_google_maps/js/google-maps-admin.js" + self.assertEqual(google_maps_js, widget.Media().js[1]) + self.assertEqual(admin_js, widget.Media().js[2]) + + def test_template_used(self): + widget = widgets.GoogleMapsAddressWidget() + self.assertEqual( + widget.template_name, + "django_google_maps/widgets/map_widget.html", + ) + + +class InlineWidgetTests(test.TestCase): + def test_render_returns_with_value(self): + widget = widgets.GoogleMapsAddressInlineWidget() + results = widget.render("locations-0-address", "New York", attrs={"attr1": 1}) + expected = '' + expected += '
' + self.assertHTMLEqual(expected, results) + + def test_render_returns_blank_for_value_when_none(self): + widget = widgets.GoogleMapsAddressInlineWidget() + results = widget.render("locations-1-address", None, attrs={"a1": 1, "a2": 2}) + expected = '' + expected += '
' + expected += '
' + self.assertHTMLEqual(expected, results) + + def test_widgets_media_js(self): + widget = widgets.GoogleMapsAddressInlineWidget() + google_maps_js = ( + "https://maps.google.com/maps/api/js?key={}&libraries=places".format( + settings.GOOGLE_MAPS_API_KEY + ) + ) + admin_js = "django_google_maps/js/google-maps-admin-inline.js" + self.assertEqual(google_maps_js, widget.Media().js[1]) + self.assertEqual(admin_js, widget.Media().js[2]) + + def test_template_used(self): + widget = widgets.GoogleMapsAddressInlineWidget() + self.assertEqual( + widget.template_name, + "django_google_maps/widgets/map_widget_inline.html", + ) diff --git a/django_google_maps/widgets.py b/django_google_maps/widgets.py index 3afac3c9..50e88e74 100644 --- a/django_google_maps/widgets.py +++ b/django_google_maps/widgets.py @@ -16,3 +16,22 @@ class Media: settings.GOOGLE_MAPS_API_KEY), 'django_google_maps/js/google-maps-admin.js', ) + + +class GoogleMapsAddressInlineWidget(widgets.TextInput): + """ + a widget that will place a google map right after the #id_locations-{index}-address + field and give it unique identifier. + """ + template_name = 'django_google_maps/widgets/map_widget_inline.html' + + class Media: + css = { + 'all': ('django_google_maps/css/google-maps-admin.css', ) + } + js = ( + 'https://ajax.googleapis.com/ajax/libs/jquery/3.1.0/jquery.min.js', + 'https://maps.google.com/maps/api/js?key={}&libraries=places'.format( + settings.GOOGLE_MAPS_API_KEY), + 'django_google_maps/js/google-maps-admin-inline.js', + ) diff --git a/sample/admin.py b/sample/admin.py index d6278220..f246775e 100644 --- a/sample/admin.py +++ b/sample/admin.py @@ -1,23 +1,28 @@ from django.contrib import admin from django.forms.widgets import TextInput -from django_google_maps.widgets import GoogleMapsAddressWidget from django_google_maps.fields import AddressField, GeoLocationField - -from sample import models +from django_google_maps.widgets import GoogleMapsAddressWidget +from sample import forms, models -class SampleModelAdmin(admin.ModelAdmin): +class LocationAdmin(admin.ModelAdmin): formfield_overrides = { - AddressField: { - 'widget': GoogleMapsAddressWidget - }, - GeoLocationField: { - 'widget': TextInput(attrs={ - 'readonly': 'readonly' - }) - }, + AddressField: {"widget": GoogleMapsAddressWidget}, + GeoLocationField: {"widget": TextInput(attrs={"readonly": "readonly"})}, } -admin.site.register(models.SampleModel, SampleModelAdmin) +class HotelLocationInlineAdmin(admin.StackedInline): + model = models.HotelLocation + form = forms.HotelLocationForm + extra = 1 + + +class HotelAdmin(admin.ModelAdmin): + list_display = ["name"] + inlines = [HotelLocationInlineAdmin] + + +admin.site.register(models.Location, LocationAdmin) +admin.site.register(models.Hotel, HotelAdmin) diff --git a/sample/forms.py b/sample/forms.py index 29a9f3b4..db53d61c 100644 --- a/sample/forms.py +++ b/sample/forms.py @@ -1,13 +1,21 @@ from django import forms -from sample.models import SampleModel -from django_google_maps.widgets import GoogleMapsAddressWidget +from django_google_maps import widgets +from sample import models -class SampleForm(forms.ModelForm): +class LocationForm(forms.ModelForm): class Meta(object): - model = SampleModel - fields = ['address', 'geolocation'] + model = models.Location + fields = ["address", "geolocation"] + widgets = {"address": widgets.GoogleMapsAddressWidget} + + +class HotelLocationForm(forms.ModelForm): + class Meta(object): + model = models.HotelLocation + fields = ["address", "geolocation", "hotel"] widgets = { - "address": GoogleMapsAddressWidget, + "address": widgets.GoogleMapsAddressInlineWidget, + "geolocation": forms.widgets.TextInput(attrs={"readonly": "readonly"}), } diff --git a/sample/models.py b/sample/models.py index 8113d52a..c70b44b6 100644 --- a/sample/models.py +++ b/sample/models.py @@ -3,9 +3,22 @@ from django_google_maps.fields import AddressField, GeoLocationField -class SampleModel(models.Model): +class Location(models.Model): address = AddressField(max_length=100) geolocation = GeoLocationField(blank=True) def __str__(self): return self.address + + +class Hotel(models.Model): + name = models.CharField(max_length=100) + + def __str__(self): + return self.name + + +class HotelLocation(models.Model): + address = AddressField(max_length=100) + geolocation = GeoLocationField(blank=True) + hotel = models.ForeignKey(Hotel, on_delete=models.CASCADE, related_name="locations") diff --git a/sample/views.py b/sample/views.py index 2bbc68a0..5f4d75a8 100644 --- a/sample/views.py +++ b/sample/views.py @@ -1,8 +1,8 @@ from django.views.generic import FormView -from sample.forms import SampleForm +from sample.forms import LocationForm -class SampleFormView(FormView): - form_class = SampleForm +class LocationFormView(FormView): + form_class = LocationForm template_name = "sample/index.html" diff --git a/urls.py b/urls.py index d8f45d19..2ef7f678 100644 --- a/urls.py +++ b/urls.py @@ -1,16 +1,17 @@ - import django from django.contrib import admin + admin.autodiscover() -if django.get_version() >= '2.0.0': +if django.get_version() >= "2.0.0": from django.urls import re_path as url else: from django.conf.urls import url -from sample.views import SampleFormView + +from sample.views import LocationFormView urlpatterns = [ - url(r'^admin/', admin.site.urls), - url(r'^$', SampleFormView.as_view()), + url(r"^admin/", admin.site.urls), + url(r"^$", LocationFormView.as_view()), ]