├── __init__.py ├── sample ├── __init__.py ├── migrations │ ├── __init__.py │ └── 0001_initial.py ├── templates │ └── sample │ │ └── index.html ├── views.py ├── models.py ├── forms.py ├── tests.py └── admin.py ├── django_google_maps ├── models.py ├── __init__.py ├── tests │ ├── __init__.py │ ├── test_app │ │ ├── __init__.py │ │ └── models.py │ ├── test_typename.py │ ├── test_widget.py │ ├── test_geolocation_field.py │ ├── test_geopt_field.py │ └── test_geopt_has_changed.py ├── templates │ └── django_google_maps │ │ └── widgets │ │ └── map_widget.html ├── static │ └── django_google_maps │ │ ├── css │ │ └── google-maps-admin.css │ │ └── js │ │ └── google-maps-admin.js ├── widgets.py └── fields.py ├── requirements ├── requirements.txt └── test.txt ├── .gitignore ├── MANIFEST.in ├── urls.py ├── manage.py ├── .github └── workflows │ ├── python-publish.yml │ └── django.yml ├── LICENSE ├── setup.py ├── README_kr.rst ├── CHANGELOG.md ├── README.rst └── settings.py /__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sample/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_google_maps/models.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_google_maps/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sample/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_google_maps/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_google_maps/tests/test_app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements/requirements.txt: -------------------------------------------------------------------------------- 1 | Django>=2.2 2 | -------------------------------------------------------------------------------- /requirements/test.txt: -------------------------------------------------------------------------------- 1 | flake8 2 | coverage 3 | mock 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/* 2 | sample_db 3 | build/* 4 | *.egg-info 5 | dist 6 | .coverage 7 | venv/ 8 | *.pyc 9 | *.pyo 10 | .tox/* 11 | -------------------------------------------------------------------------------- /sample/templates/sample/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{form.media}} 4 | 5 | {{form.as_p}} 6 | 7 | -------------------------------------------------------------------------------- /sample/views.py: -------------------------------------------------------------------------------- 1 | from django.views.generic import FormView 2 | 3 | from sample.forms import SampleForm 4 | 5 | 6 | class SampleFormView(FormView): 7 | form_class = SampleForm 8 | template_name = "sample/index.html" 9 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | 2 | include LICENSE 3 | include README.md 4 | include CHANGELOG.md 5 | include MANIFEST.in 6 | include requirements/*.txt 7 | 8 | recursive-include django_google_maps/static * 9 | recursive-include django_google_maps/templates * 10 | -------------------------------------------------------------------------------- /django_google_maps/tests/test_app/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django_google_maps.fields import AddressField, GeoLocationField 3 | 4 | 5 | class Person(models.Model): 6 | address = AddressField(max_length=100) 7 | geolocation = GeoLocationField(blank=True) 8 | -------------------------------------------------------------------------------- /django_google_maps/templates/django_google_maps/widgets/map_widget.html: -------------------------------------------------------------------------------- 1 |
2 | {% include "django/forms/widgets/text.html" %} 3 |
4 |
5 |
6 | 7 |
-------------------------------------------------------------------------------- /sample/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from django_google_maps.fields import AddressField, GeoLocationField 4 | 5 | 6 | class SampleModel(models.Model): 7 | address = AddressField(max_length=100) 8 | geolocation = GeoLocationField(blank=True) 9 | 10 | def __str__(self): 11 | return self.address 12 | -------------------------------------------------------------------------------- /sample/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from sample.models import SampleModel 3 | from django_google_maps.widgets import GoogleMapsAddressWidget 4 | 5 | 6 | class SampleForm(forms.ModelForm): 7 | 8 | class Meta(object): 9 | model = SampleModel 10 | fields = ['address', 'geolocation'] 11 | widgets = { 12 | "address": GoogleMapsAddressWidget, 13 | } 14 | -------------------------------------------------------------------------------- /urls.py: -------------------------------------------------------------------------------- 1 | 2 | import django 3 | from django.contrib import admin 4 | admin.autodiscover() 5 | 6 | 7 | if django.get_version() >= '2.0.0': 8 | from django.urls import re_path as url 9 | else: 10 | from django.conf.urls import url 11 | from sample.views import SampleFormView 12 | 13 | urlpatterns = [ 14 | url(r'^admin/', admin.site.urls), 15 | url(r'^$', SampleFormView.as_view()), 16 | ] 17 | -------------------------------------------------------------------------------- /django_google_maps/tests/test_typename.py: -------------------------------------------------------------------------------- 1 | from django import test 2 | from django_google_maps.fields import typename 3 | 4 | 5 | class TypeNameTests(test.TestCase): 6 | def test_simple_type_returns_type_name_as_string(self): 7 | self.assertEqual("str", typename("x")) 8 | 9 | def test_class_object(self): 10 | class X: 11 | pass 12 | 13 | self.assertEqual("type", typename(X)) 14 | -------------------------------------------------------------------------------- /sample/tests.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file demonstrates writing tests using the unittest module. These will pass 3 | when you run "manage.py test". 4 | 5 | Replace this with more appropriate tests for your application. 6 | """ 7 | 8 | from django.test import TestCase 9 | 10 | 11 | class SimpleTest(TestCase): 12 | 13 | def test_basic_addition(self): 14 | """ 15 | Tests that 1 + 1 always equals 2. 16 | """ 17 | self.assertEqual(1 + 1, 2) 18 | -------------------------------------------------------------------------------- /sample/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.forms.widgets import TextInput 3 | 4 | from django_google_maps.widgets import GoogleMapsAddressWidget 5 | from django_google_maps.fields import AddressField, GeoLocationField 6 | 7 | from sample import models 8 | 9 | 10 | class SampleModelAdmin(admin.ModelAdmin): 11 | formfield_overrides = { 12 | AddressField: { 13 | 'widget': GoogleMapsAddressWidget 14 | }, 15 | GeoLocationField: { 16 | 'widget': TextInput(attrs={ 17 | 'readonly': 'readonly' 18 | }) 19 | }, 20 | } 21 | 22 | 23 | admin.site.register(models.SampleModel, SampleModelAdmin) 24 | -------------------------------------------------------------------------------- /sample/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import django_google_maps.fields 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='SampleModel', 15 | fields=[ 16 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 17 | ('address', django_google_maps.fields.AddressField(max_length=100)), 18 | ('geolocation', django_google_maps.fields.GeoLocationField(max_length=100, blank=True)), 19 | ], 20 | options={}, 21 | bases=(models.Model, ), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | 12 | 13 | # #!/usr/bin/env python 14 | # from django.core.management import execute_manager 15 | # import imp 16 | # try: 17 | # imp.find_module('settings') # Assumed to be in the same directory. 18 | # except ImportError: 19 | # import sys 20 | # sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n" % __file__) 21 | # sys.exit(1) 22 | 23 | # import settings 24 | 25 | # if __name__ == "__main__": 26 | # execute_manager(settings) 27 | -------------------------------------------------------------------------------- /django_google_maps/static/django_google_maps/css/google-maps-admin.css: -------------------------------------------------------------------------------- 1 | @media (min-width: 848px) { 2 | .main.shifted .map_canvas_wrapper { 3 | margin-left: 0; 4 | } 5 | } 6 | 7 | #id_address { 8 | width: 40em; 9 | } 10 | 11 | .map_widget_wrapper { 12 | display: flex; 13 | flex-direction: column; 14 | width: 100%; 15 | gap: 0.5rem; 16 | } 17 | 18 | .map_canvas_wrapper { 19 | width: 100%; 20 | } 21 | 22 | #map_canvas { 23 | width: 100%; 24 | height: 40em; 25 | } 26 | 27 | #map_message_box { 28 | background-color: #eff6ff; 29 | color: #1e40af; 30 | padding: 12px 20px; 31 | border-radius: 8px; 32 | border: 1px solid #bfdbfe; 33 | display: none; /* Hidden by default */ 34 | font-size: 0.9rem; 35 | opacity: 0; 36 | transition: opacity 0.3s ease-in-out; 37 | float: none; 38 | } 39 | 40 | #map_message_box.show { 41 | display: block; 42 | opacity: 1; 43 | } 44 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using trusted publishers 2 | # For more information see: https://packaging.python.org/en/latest/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/ 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-latest 13 | 14 | permissions: 15 | id-token: write # This is important for OIDC 16 | contents: read # Recommended for checkout 17 | 18 | steps: 19 | - uses: actions/checkout@v5 20 | - name: Set up Python 21 | uses: actions/setup-python@v6 22 | with: 23 | python-version: '3.x' 24 | - name: Install pypa/build 25 | run: | 26 | python -m pip install build --user 27 | - name: Build Package 28 | run: python -m build 29 | - name: Publish to Pypi 30 | uses: pypa/gh-action-pypi-publish@release/v1 31 | # This action handles building and publishing automatically 32 | # No need to manually run setup.py or twine 33 | # No secrets needed here for PyPI username/password 34 | # The action will use the OIDC token from the permissions block 35 | -------------------------------------------------------------------------------- /django_google_maps/widgets.py: -------------------------------------------------------------------------------- 1 | import django 2 | from django.conf import settings 3 | from django.forms import widgets 4 | 5 | # Check if we're on Django 5.2+ 6 | USE_SCRIPT_OBJECT = django.VERSION >= (5, 2) 7 | 8 | if USE_SCRIPT_OBJECT: 9 | from django.forms.widgets import Script 10 | 11 | 12 | class GoogleMapsAddressWidget(widgets.TextInput): 13 | """a widget that will place a google map right after the #id_address field""" 14 | 15 | template_name = "django_google_maps/widgets/map_widget.html" 16 | 17 | class Media: 18 | css = {"all": ("django_google_maps/css/google-maps-admin.css",)} 19 | 20 | if USE_SCRIPT_OBJECT: 21 | js = ( 22 | Script( 23 | f'https://maps.googleapis.com/maps/api/js?key={settings.GOOGLE_MAPS_API_KEY}&loading=async&callback=initGoogleMap', # noqa: E501 24 | **{ 25 | "async": True, 26 | }), 27 | 'django_google_maps/js/google-maps-admin.js', 28 | ) 29 | else: 30 | js = ( 31 | f"https://maps.googleapis.com/maps/api/js?key={settings.GOOGLE_MAPS_API_KEY}&loading=async&callback=initGoogleMap", # noqa: E501 32 | 'django_google_maps/js/google-maps-admin.js', 33 | ) 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011, Aaron Madison 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 19 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 22 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /.github/workflows/django.yml: -------------------------------------------------------------------------------- 1 | name: Django CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | strategy: 14 | max-parallel: 4 15 | fail-fast: false 16 | matrix: 17 | python-version: ['3.10', '3.11', '3.12'] 18 | django-version: ['4.2', '5.2'] 19 | include: 20 | - python-version: '3.9' 21 | django-version: '2.2' 22 | - python-version: '3.9' 23 | django-version: '3.2' 24 | - python-version: '3.10' 25 | django-version: '3.2' 26 | - python-version: '3.9' 27 | django-version: '4.2' 28 | - python-version: '3.13' 29 | django-version: '5.2' 30 | steps: 31 | - uses: actions/checkout@v4 32 | - name: Set up Python ${{ matrix.python-version }} 33 | uses: actions/setup-python@v5 34 | with: 35 | python-version: ${{ matrix.python-version }} 36 | - name: Install Dependencies 37 | run: | 38 | python -m pip install --upgrade pip 39 | pip install -r requirements/test.txt 40 | pip install -r requirements/requirements.txt 41 | pip install -q Django==${{ matrix.django-version }} 42 | - name: Run Tests 43 | run: | 44 | coverage run --source=django_google_maps manage.py test 45 | coverage report --show-missing 46 | flake8 django_google_maps --max-line-length=120 --max-complexity=4 47 | -------------------------------------------------------------------------------- /django_google_maps/tests/test_widget.py: -------------------------------------------------------------------------------- 1 | from django import test 2 | from django.conf import settings 3 | from django_google_maps.widgets import GoogleMapsAddressWidget 4 | 5 | 6 | class WidgetTests(test.TestCase): 7 | def test_render_returns_xxxxxxx(self): 8 | widget = GoogleMapsAddressWidget() 9 | results = widget.render("name", "value", attrs={"a1": 1, "a2": 2}) 10 | expected = '
' 11 | expected += '' 12 | expected += '
' 13 | expected += '
' 14 | expected += '
' 15 | self.assertHTMLEqual(expected, results) 16 | 17 | def test_render_returns_blank_for_value_when_none(self): 18 | widget = GoogleMapsAddressWidget() 19 | results = widget.render("name", None, attrs={"a1": 1, "a2": 2}) 20 | expected = '
' 21 | expected += '' 22 | expected += '
' 23 | expected += '
' 24 | expected += '
' 25 | self.assertHTMLEqual(expected, results) 26 | 27 | def test_maps_js_uses_api_key(self): 28 | widget = GoogleMapsAddressWidget() 29 | google_maps_js = ( 30 | f"https://maps.googleapis.com/maps/api/js?key={settings.GOOGLE_MAPS_API_KEY}&loading=async&callback=initGoogleMap" # noqa: E501 31 | ) 32 | self.assertEqual(google_maps_js, widget.Media().js[0]) 33 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | 2 | import os 3 | from setuptools import setup 4 | 5 | README = os.path.join(os.path.dirname(__file__), 'README.rst') 6 | LONG_DESCRIPTION = open(README, 'r').read() 7 | CLASSIFIERS = [ 8 | "Development Status :: 4 - Beta", 9 | "Environment :: Web Environment", 10 | "Framework :: Django", 11 | "Framework :: Django", 12 | "Framework :: Django :: 2.2", 13 | "Framework :: Django :: 3.2", 14 | "Framework :: Django :: 4.0", 15 | "Framework :: Django :: 5.0", 16 | "Intended Audience :: Developers", 17 | "License :: OSI Approved :: BSD License", 18 | "Operating System :: OS Independent", 19 | "Programming Language :: Python", 20 | "Programming Language :: Python :: 3.9", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.11", 23 | "Programming Language :: Python :: 3.12", 24 | "Programming Language :: Python :: 3.13", 25 | "Topic :: Software Development", 26 | "Topic :: Software Development :: Libraries :: Application Frameworks", 27 | ] 28 | 29 | setup( 30 | name="django-google-maps", 31 | version='0.14.0', 32 | author="Aaron Madison", 33 | author_email="aaron.l.madison@gmail.com", 34 | description="Plugs google maps V3 api into Django admin.", 35 | long_description=LONG_DESCRIPTION, 36 | long_description_content_type='text/x-rst', 37 | url="https://github.com/madisona/django-google-maps", 38 | packages=("django_google_maps",), 39 | include_package_data=True, 40 | install_requires=open('requirements/requirements.txt').read().splitlines(), 41 | tests_require=open('requirements/test.txt').read().splitlines(), 42 | classifiers=CLASSIFIERS, 43 | zip_safe=False, 44 | ) 45 | -------------------------------------------------------------------------------- /django_google_maps/tests/test_geolocation_field.py: -------------------------------------------------------------------------------- 1 | from django import test 2 | from django_google_maps.tests.test_app import models 3 | from django_google_maps.fields import GeoPt 4 | 5 | 6 | class GeoLocationFieldTests(test.TestCase): 7 | def test_getting_lat_lon_from_model_given_string(self): 8 | sut_create = models.Person.objects.create(geolocation="45,90") 9 | sut = models.Person.objects.get(pk=sut_create.pk) 10 | self.assertEqual(45, sut.geolocation.lat) 11 | self.assertEqual(90, sut.geolocation.lon) 12 | 13 | def test_getting_lat_lon_from_model_given_pt(self): 14 | sut_create = models.Person.objects.create(geolocation=GeoPt("45,90")) 15 | sut = models.Person.objects.get(pk=sut_create.pk) 16 | self.assertEqual(45, sut.geolocation.lat) 17 | self.assertEqual(90, sut.geolocation.lon) 18 | 19 | def test_getting_lat_lon_from_model_in_db_given_string(self): 20 | sut_create = models.Person.objects.create(geolocation="45,90") 21 | sut = models.Person.objects.get(pk=sut_create.pk) 22 | self.assertEqual(45, sut.geolocation.lat) 23 | self.assertEqual(90, sut.geolocation.lon) 24 | 25 | def test_exact_match_query(self): 26 | sut = models.Person.objects.create(geolocation="45,90") 27 | result = models.Person.objects.get(geolocation__exact=GeoPt("45,90")) 28 | self.assertEqual(result, sut) 29 | 30 | def test_in_match_query(self): 31 | sut = models.Person.objects.create(geolocation="45,90") 32 | result = models.Person.objects.get(geolocation__in=[GeoPt("45,90")]) 33 | self.assertEqual(result, sut) 34 | 35 | def test_value_to_string_with_point(self): 36 | sut = models.Person.objects.create(geolocation=GeoPt("45,90")) 37 | field = models.Person._meta.fields[-1] 38 | self.assertEqual("45.0,90.0", field.value_to_string(sut)) 39 | 40 | def test_value_to_string_with_string(self): 41 | sut = models.Person.objects.create(geolocation="45,90") 42 | field = models.Person._meta.fields[-1] 43 | self.assertEqual("45.0,90.0", field.value_to_string(sut)) 44 | 45 | def test_get_prep_value_returns_none_when_none(self): 46 | field = models.Person._meta.fields[-1] 47 | result = field.get_prep_value(None) 48 | self.assertEqual(None, result) 49 | -------------------------------------------------------------------------------- /README_kr.rst: -------------------------------------------------------------------------------- 1 | ``django-google-maps``\ 는 Django 버전 1.11+에서 Django 모델 용 V3 API에 2 | 대한 기본 훅을 제공하는 간단한 애플리케이션입니다. 3 | 4 | ``django-google-maps`` 버전 (0.7.0)부터는 Django가 위젯 템플릿 렌더링 5 | 시스템을 변경했기 때문에 Django 1.11+가 필요합니다. 버전 0.8.0은 Django 6 | 2.0+를 지원하며 Python 2.7 지원을 제거합니다. 7 | 8 | 저는 관리자 패널의 누군가가 자유 형식 주소를 입력하고, 주소가 9 | 변경되고지도 상에 그려 지도록 허용하고 있습니다. 위치가 100 % 정확하지 10 | 않은 경우 사용자는 마커를 올바른 지점으로 드래그 할 수 있으며 지리적 11 | 좌표가 업데이트됩니다. 12 | 13 | 14 | 용법: 15 | ----- 16 | 17 | -``settings.py``\ 에\ ``django_google_maps`` 앱을 포함 시키십시오. - 18 | Google Maps API 키를\ ``settings.py``\ 에 ’GOOGLE_MAPS_API_KEY\` (으)로 19 | 추가하십시오. - 주소 필드와 위치 정보 필드가 모두있는 모델을 만듭니다. 20 | 21 | .. code:: python 22 | 23 | from django.db import models 24 | from django_google_maps import fields as map_fields 25 | 26 | class Rental(models.Model): 27 | address = map_fields.AddressField(max_length=200) 28 | geolocation = map_fields.GeoLocationField(max_length=100) 29 | 30 | -``admin.py``\ 에 다음을 formfield_override로 포함하십시오. 31 | 32 | .. code:: python 33 | 34 | from django.contrib import admin 35 | from django_google_maps import widgets as map_widgets 36 | from django_google_maps import fields as map_fields 37 | 38 | class RentalAdmin(admin.ModelAdmin): 39 | formfield_overrides = { 40 | map_fields.AddressField: {'widget': map_widgets.GoogleMapsAddressWidget}, 41 | } 42 | 43 | -지도 유형 (기본적으로\ ``hybrid ')을 변경하려면,``\ AddressField\` 44 | 위젯에 html 속성을 추가 할 수 있습니다. 허용되는 값 목록은 ‘하이브리드’, 45 | ‘로드맵’, ‘위성’, ’지형’입니다. 46 | 47 | .. code:: python 48 | 49 | from django.contrib import admin 50 | from django_google_maps import widgets as map_widgets 51 | from django_google_maps import fields as map_fields 52 | 53 | class RentalAdmin(admin.ModelAdmin): 54 | formfield_overrides = { 55 | map_fields.AddressField: { 56 | 'widget': map_widgets.GoogleMapsAddressWidget(attrs={'data-map-type': 'roadmap'})}, 57 | } 58 | 59 | 그게 당신이 시작하는 데 필요한 모든 것이어야합니다. 60 | 61 | 나는 또한 관리자가 읽기 전용으로 geolocation 필드를 만들어 사용자 62 | (우연히)가 무의미한 값으로 변경하지 않도록하고 싶습니다. 입력란에 유효성 63 | 검사가 있으므로 잘못된 값을 입력 할 수는 없지만 의도 한 주소와 거의 64 | 일치하지 않는 내용을 입력 할 수 있습니다. 65 | 66 | 사용자에게 다시 주소를 표시 할 때 모델에 저장된 지오 코디네이터를 67 | 사용하여지도를 요청하십시오. 어쩌면 언젠가는 내가 그 모델을 구축 할 68 | 방법을 만들 수 있는지 살펴볼 것입니다. 69 | 70 | .. |Build Status| image:: https://travis-ci.org/madisona/django-google-maps.png 71 | :target: https://travis-ci.org/madisona/django-google-maps -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [0.14.0] - 2025-09-21 2 | - Big thanks to @RobKuipers - most of the improvements in this release were his contributions 3 | - Fixed bug where `GeoLocationField` was always marked as "changed" 4 | - Allow comparing `GeoPoint` object with string lat,lng 5 | - Removed jQuery dependency, now straight javascript 6 | - Added: Django 5.2 support 7 | - Drop CI checks for Python 3.7 & 3.8 8 | - Improved styling in Django admin for newer versions 9 | - Updated to Google Maps AdvancedMarkerElement 10 | - Load Google Maps Async 11 | 12 | ## [0.13.0] - 2022-03-22 13 | - Added: Django 4.0 support 14 | - Added: Python 3.10 support 15 | 16 | ## [0.12.4] - 2021-04-06 17 | - Added: Django 3.2 support 18 | 19 | ## [0.12.3] - 2021-04-04 20 | - Fixed: Fixed bug catching malformed lat/lon in `GeoPt` field 21 | - Fixed: Removed redundant `STATIC_URL` in `GoogleMapsAddressWidget` 22 | - Changed: Updated css for map to be responsive in the admin 23 | - Changed: Moved CI to Github Actions 24 | 25 | ## [0.12.2] - 2020-08-05 26 | - Added: Django 3.1 support 27 | 28 | ## [0.12.1] - 2020-02-03 29 | - Fixed: Fixed install requirement for Django 3 30 | 31 | ## [0.12.0] - 2020-02-02 32 | - Changed: Updated for `from_db_value` `context` parameter deprecation 33 | - Added: Django 3.0 support 34 | - Removed: Python 2.0 support & django compatibility helpers 35 | 36 | ## [0.11.0] - 2019-04-19 37 | - Added: Django 2.2 support 38 | - Removed: Django 1.10 39 | 40 | ## [0.10.1] - 2018-02-20 41 | - Fixed: Install issue - `setup.py` now properly reads in requirements 42 | 43 | ## [0.10.0] - 2018-02-15 44 | - Added: Allow MapType to be customized 45 | 46 | ## [0.9.0] - 2018-01-19 47 | - Added: Google Places Autocomplete 48 | 49 | ## [0.8.0] - 2018-01-12 50 | - Added: Django 2.0 support 51 | - Removed: python 2.7 support 52 | 53 | ## [0.7.0] - 2017-04-10 54 | - Added: Django 1.11 support (new widget rendering) 55 | - Removed: Django <= 1.10 support 56 | 57 | ## [0.6.0] - 2016-11-28 58 | - Added: Django 1.10 support 59 | - Removed: Django <= 1.7 support 60 | 61 | ## [0.5.0] - 2016-08-12 62 | - Added: Allow using Google Maps API Key 63 | - Changed: jquery version to 3.1.0 64 | 65 | ## [0.4.0] - 2016-07-05 66 | - Added: Python 3 Compatibility 67 | - Fixed: Adjusted widgets imports to maintain backwards compatibility 68 | 69 | ## [0.3.0] - 2015-04-09 70 | - Added: Travis integration 71 | - Added: Django 1.7 & 1.8 support 72 | - Added: additional test coverage 73 | - Added: Map updates affecting geolocation now triggers change event on geolocation field 74 | - Changed: jquery version to 1.11.2 75 | 76 | ## [0.2.3] - 2013-10-08 77 | - Changed: Google Maps JS to always load over https 78 | 79 | ## [0.2.2] - 2012-12-13 80 | - Added: Django 1.4 support 81 | 82 | ## [0.2.1] - 2011-06-28 83 | - Added: `__eq__` method for GeoPt field to allow comparison 84 | 85 | ## [0.2.0] - 2011-06-27 86 | - Initial version 87 | 88 | 89 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ``django-google-maps`` is a simple application that provides the basic 2 | hooks into google maps V3 api for use in Django models from Django 3 | version 1.11+. 4 | 5 | Starting with ``django-google-maps`` version (0.7.0), Django 1.11+ is 6 | required because Django changed their widget template rendering system. 7 | Version 0.8.0 supports Django 2.0+, and as such removes support for 8 | Python 2.7 9 | 10 | I’m using this to allow someone from the admin panels to type a freeform 11 | address, have the address geocoded on change and plotted on the map. If 12 | the location is not 100% correct, the user can drag the marker to the 13 | correct spot and the geo coordinates will update. 14 | 15 | 16 | USAGE: 17 | ------ 18 | 19 | - include the ``django_google_maps`` app in your ``settings.py`` 20 | 21 | - Add your Google Maps API Key in your ``settings.py`` as 22 | ``GOOGLE_MAPS_API_KEY`` 23 | 24 | - create a model that has both an address field and geolocation field 25 | 26 | .. code:: python 27 | 28 | from django.db import models 29 | from django_google_maps import fields as map_fields 30 | 31 | class Rental(models.Model): 32 | address = map_fields.AddressField(max_length=200) 33 | geolocation = map_fields.GeoLocationField(max_length=100) 34 | 35 | - in the ``admin.py`` include the following as a formfield_override 36 | 37 | .. code:: python 38 | 39 | from django.contrib import admin 40 | from django_google_maps import widgets as map_widgets 41 | from django_google_maps import fields as map_fields 42 | 43 | class RentalAdmin(admin.ModelAdmin): 44 | formfield_overrides = { 45 | map_fields.AddressField: {'widget': map_widgets.GoogleMapsAddressWidget}, 46 | } 47 | 48 | - To change the map type (``hybrid`` by default), you can add an html 49 | attribute on the ``AddressField`` widget. The list of allowed values 50 | is: ``hybrid``, ``roadmap``, ``satellite``, ``terrain`` 51 | 52 | .. code:: python 53 | 54 | from django.contrib import admin 55 | from django_google_maps import widgets as map_widgets 56 | from django_google_maps import fields as map_fields 57 | 58 | class RentalAdmin(admin.ModelAdmin): 59 | formfield_overrides = { 60 | map_fields.AddressField: { 61 | 'widget': map_widgets.GoogleMapsAddressWidget(attrs={'data-map-type': 'roadmap'})}, 62 | } 63 | 64 | - To change the autocomplete options, you can add an html attribute on 65 | the ``AddressField`` widget. See 66 | https://developers.google.com/maps/documentation/javascript/places-autocomplete#add_autocomplete 67 | for a list of available options 68 | 69 | .. code:: python 70 | 71 | import json from django.contrib import admin 72 | from django_google_maps import widgets as map_widgets 73 | from django_google_maps import fields as map_fields 74 | 75 | class RentalAdmin(admin.ModelAdmin): formfield_overrides = { 76 | map_fields.AddressField: { 'widget': 77 | map_widgets.GoogleMapsAddressWidget(attrs={ 78 | 'data-autocomplete-options': json.dumps({ 'types': ['geocode', 79 | 'establishment'], 'componentRestrictions': { 80 | 'country': 'us' 81 | } 82 | }) 83 | }) 84 | }, 85 | } 86 | 87 | That should be all you need to get started. 88 | 89 | I also like to make the geolocation field readonly in the admin so a user 90 | (myself) doesn't accidentally change it to a nonsensical value. There is 91 | validation on the field so you can't enter an incorrect value, but you could 92 | enter something that is not even close to the address you intended. 93 | 94 | When you're displaying the address back to the user, just request the map 95 | using the geocoordinates that were saved in your model. Maybe sometime when 96 | I get around to it I'll see if I can create a method that will build that 97 | into the model. 98 | -------------------------------------------------------------------------------- /django_google_maps/tests/test_geopt_field.py: -------------------------------------------------------------------------------- 1 | from django import test 2 | from django.core import exceptions 3 | from django.utils.encoding import force_str 4 | 5 | from django_google_maps import fields 6 | 7 | 8 | class GeoPtFieldTests(test.TestCase): 9 | def test_sets_lat_lon_on_initialization(self): 10 | geo_pt = fields.GeoPt("15.001,32.001") 11 | self.assertEqual(15.001, geo_pt.lat) 12 | self.assertEqual(32.001, geo_pt.lon) 13 | 14 | def test_uses_lat_comma_lon_as_unicode_representation(self): 15 | lat_lon_string = "15.001,32.001" 16 | geo_pt = fields.GeoPt(lat_lon_string) 17 | self.assertEqual(lat_lon_string, force_str(geo_pt)) 18 | 19 | def test_two_GeoPts_with_same_lat_lon_should_be_equal(self): 20 | geo_pt_1 = fields.GeoPt("15.001,32.001") 21 | geo_pt_2 = fields.GeoPt("15.001,32.001") 22 | self.assertEqual(geo_pt_1, geo_pt_2) 23 | 24 | def test_two_GeoPts_with_different_lat_should_not_be_equal(self): 25 | geo_pt_1 = fields.GeoPt("15.001,32.001") 26 | geo_pt_2 = fields.GeoPt("20.001,32.001") 27 | self.assertNotEqual(geo_pt_1, geo_pt_2) 28 | 29 | def test_two_GeoPts_with_different_lon_should_not_be_equal(self): 30 | geo_pt_1 = fields.GeoPt("15.001,32.001") 31 | geo_pt_2 = fields.GeoPt("15.001,62.001") 32 | self.assertNotEqual(geo_pt_1, geo_pt_2) 33 | 34 | def test_is_equal_when_comparison_str_GeoPt_object(self): 35 | geo_pt_1 = fields.GeoPt("15.001,32.001") 36 | geo_pt_2 = "15.001,32.001" 37 | self.assertEqual(geo_pt_1, geo_pt_2) 38 | 39 | def test_is_not_equal_when_comparison_str_GeoPt_object(self): 40 | geo_pt_1 = fields.GeoPt("15.001,32.001") 41 | geo_pt_2 = "25.001,32.001" 42 | self.assertNotEqual(geo_pt_1, geo_pt_2) 43 | 44 | def test_compare_empty_GeoPt_to_None_object(self): 45 | geo_pt_1 = fields.GeoPt(None) 46 | geo_pt_2 = None 47 | self.assertEqual(geo_pt_1, geo_pt_2) 48 | 49 | def test_allows_GeoPt_instantiated_with_empty_string(self): 50 | geo_pt = fields.GeoPt("") 51 | self.assertEqual(None, geo_pt.lat) 52 | self.assertEqual(None, geo_pt.lon) 53 | 54 | def test_uses_empty_string_as_unicode_representation_for_empty_GeoPt(self): 55 | geo_pt = fields.GeoPt("") 56 | self.assertEqual("", force_str(geo_pt)) 57 | 58 | def test_splits_geo_point_on_comma(self): 59 | pt = fields.GeoPt("15.001,32.001") 60 | self.assertEqual("15.001", str(pt.lat)) 61 | self.assertEqual("32.001", str(pt.lon)) 62 | 63 | def test_raises_error_when_attribute_error_on_split(self): 64 | class Fake(object): 65 | pass 66 | 67 | with self.assertRaises(exceptions.ValidationError): 68 | fields.GeoPt(Fake) 69 | 70 | def test_raises_error_when_type_error_on_split(self): 71 | with self.assertRaises(exceptions.ValidationError): 72 | x, y = fields.GeoPt("x,x") 73 | 74 | def test_returns_float_value_when_valid_value(self): 75 | geo_pt = fields.GeoPt("45.005,180") 76 | self.assertEqual(45.005, geo_pt.lat) 77 | 78 | def test_raises_exception_when_value_is_out_of_upper_range(self): 79 | with self.assertRaises(exceptions.ValidationError): 80 | fields.GeoPt("180,180") 81 | 82 | def test_raises_exception_when_value_is_out_of_lower_range(self): 83 | with self.assertRaises(exceptions.ValidationError): 84 | fields.GeoPt("-180,180") 85 | 86 | def test_len_returns_len_of_unicode_value(self): 87 | geo_pt = fields.GeoPt("84,12") 88 | self.assertEqual(9, len(geo_pt)) 89 | 90 | def test_raises_exception_not_enough_values_to_unpack(self): 91 | with self.assertRaises(exceptions.ValidationError): 92 | fields.GeoPt("22") 93 | 94 | def test_raises_exception_too_many_values_to_unpack(self): 95 | with self.assertRaises(exceptions.ValidationError): 96 | fields.GeoPt("22,50,90") 97 | -------------------------------------------------------------------------------- /django_google_maps/tests/test_geopt_has_changed.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.db import models 3 | from django.test import TestCase 4 | 5 | from django_google_maps.fields import GeoPt, GeoLocationField 6 | 7 | 8 | class GeoLocationFieldHasChangedTests(TestCase): 9 | """ 10 | Integration tests for the has_changed functionality with forms. 11 | """ 12 | 13 | def setUp(self): 14 | """Set up a model and form for testing.""" 15 | 16 | class TestModel(models.Model): 17 | location = GeoLocationField(max_length=100, blank=True) 18 | 19 | class Meta: 20 | app_label = "django_google_maps" 21 | 22 | self.TestModel = TestModel 23 | 24 | # Create a form for testing 25 | class TestForm(forms.ModelForm): 26 | class Meta: 27 | model = TestModel 28 | fields = ["location"] 29 | 30 | self.TestForm = TestForm 31 | 32 | def test_has_changed_false_when_same_value(self): 33 | """ 34 | Test that has_changed returns False when the value hasn't actually changed. 35 | This is the main bug that was fixed. 36 | """ 37 | # Create a GeoPt object (simulating initial data from database) 38 | initial_value = GeoPt(40.7128, -74.0060) 39 | 40 | # Create form data (simulating form submission with same value) 41 | form_data = {"location": "40.7128,-74.0060"} 42 | 43 | # Create form with initial data 44 | form = self.TestForm(data=form_data, initial={"location": initial_value}) 45 | 46 | # The field should not be marked as changed 47 | self.assertFalse(form.has_changed()) 48 | self.assertNotIn("location", form.changed_data) 49 | 50 | def test_has_changed_true_when_value_actually_changed(self): 51 | """ 52 | Test that has_changed returns True when the value has actually changed. 53 | """ 54 | # Create a GeoPt object (simulating initial data from database) 55 | initial_value = GeoPt(40.7128, -74.0060) 56 | 57 | # Create form data with different coordinates 58 | form_data = {"location": "41.0000,-73.0000"} 59 | 60 | # Create form with initial data 61 | form = self.TestForm(data=form_data, initial={"location": initial_value}) 62 | 63 | # The field should be marked as changed 64 | self.assertTrue(form.has_changed()) 65 | self.assertIn("location", form.changed_data) 66 | 67 | def test_has_changed_false_with_empty_values(self): 68 | """ 69 | Test that has_changed handles empty values correctly. 70 | """ 71 | # Test empty initial and empty form data 72 | form_data = {"location": ""} 73 | form = self.TestForm(data=form_data, initial={"location": None}) 74 | 75 | self.assertFalse(form.has_changed()) 76 | self.assertNotIn("location", form.changed_data) 77 | 78 | def test_has_changed_true_from_empty_to_value(self): 79 | """ 80 | Test that has_changed returns True when going from empty to a value. 81 | """ 82 | # Empty initial, non-empty form data 83 | form_data = {"location": "40.7128,-74.0060"} 84 | form = self.TestForm(data=form_data, initial={"location": None}) 85 | 86 | self.assertTrue(form.has_changed()) 87 | self.assertIn("location", form.changed_data) 88 | 89 | def test_has_changed_true_from_value_to_empty(self): 90 | """ 91 | Test that has_changed returns True when going from a value to empty. 92 | """ 93 | # Non-empty initial, empty form data 94 | initial_value = GeoPt(40.7128, -74.0060) 95 | form_data = {"location": ""} 96 | form = self.TestForm(data=form_data, initial={"location": initial_value}) 97 | 98 | self.assertTrue(form.has_changed()) 99 | self.assertIn("location", form.changed_data) 100 | 101 | def test_widget_has_changed_method(self): 102 | """ 103 | Test the widget's has_changed method directly. 104 | """ 105 | # Get the field from the form 106 | form = self.TestForm() 107 | field = form.fields["location"] 108 | widget = field.widget 109 | 110 | # Test the widget's has_changed method directly 111 | initial_geopt = GeoPt(40.7128, -74.0060) 112 | same_string = "40.7128,-74.0060" 113 | different_string = "41.0000,-73.0000" 114 | 115 | # If using custom widget, test its has_changed method 116 | if hasattr(widget, "has_changed"): 117 | self.assertFalse(widget.has_changed(initial_geopt, same_string)) 118 | self.assertTrue(widget.has_changed(initial_geopt, different_string)) 119 | -------------------------------------------------------------------------------- /django_google_maps/fields.py: -------------------------------------------------------------------------------- 1 | # The core of this module was adapted from Google AppEngine's 2 | # GeoPt field, so I've included their copyright and license. 3 | # 4 | # Copyright 2007 Google Inc. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | from django.core import exceptions 20 | from django.db import models 21 | from django.utils.encoding import force_str 22 | 23 | __all__ = ("AddressField", "GeoLocationField") 24 | 25 | 26 | def typename(obj): 27 | """Returns the type of obj as a string. More descriptive and specific than 28 | type(obj), and safe for any object, unlike __class__.""" 29 | if hasattr(obj, "__class__"): 30 | return getattr(obj, "__class__").__name__ 31 | else: 32 | return type(obj).__name__ 33 | 34 | 35 | class GeoPt(object): 36 | """A geographical point.""" 37 | 38 | lat = None 39 | lon = None 40 | 41 | def __init__(self, lat, lon=None): 42 | """ 43 | If the model field has 'blank=True' or 'null=True' then 44 | we can't always expect the GeoPt to be instantiated with 45 | a valid value. In this case we'll let GeoPt be instantiated 46 | as an empty item, and the string representation should be 47 | an empty string instead of 'lat,lon'. 48 | """ 49 | if not lat: 50 | return 51 | 52 | if lon is None: 53 | lat, lon = self._split_geo_point(lat) 54 | self.lat = self._validate_geo_range(lat, 90) 55 | self.lon = self._validate_geo_range(lon, 180) 56 | 57 | def __str__(self): 58 | if self.lat is not None and self.lon is not None: 59 | return "%s,%s" % (self.lat, self.lon) 60 | return "" 61 | 62 | def __eq__(self, other): 63 | if other is None: 64 | other = GeoPt(None) 65 | elif isinstance(other, str): 66 | other = GeoPt(other) 67 | if isinstance(other, GeoPt): 68 | return bool(self.lat == other.lat and self.lon == other.lon) 69 | return NotImplemented 70 | 71 | def __len__(self): 72 | return len(force_str(self)) 73 | 74 | def _split_geo_point(self, geo_point): 75 | """splits the geo point into lat and lon""" 76 | try: 77 | lat, lon = geo_point.split(",") 78 | return lat, lon 79 | except (AttributeError, ValueError): 80 | m = 'Expected a "lat,long" formatted string; received %s (a %s).' 81 | raise exceptions.ValidationError(m % (geo_point, typename(geo_point))) 82 | 83 | def _validate_geo_range(self, geo_part, range_val): 84 | try: 85 | geo_part = float(geo_part) 86 | if abs(geo_part) > range_val: 87 | m = "Must be between -%s and %s; received %s" 88 | raise exceptions.ValidationError(m % (range_val, range_val, geo_part)) 89 | except (TypeError, ValueError): 90 | raise exceptions.ValidationError( 91 | "Expected float, received %s (a %s)." % (geo_part, typename(geo_part)) 92 | ) 93 | return geo_part 94 | 95 | 96 | class AddressField(models.CharField): 97 | pass 98 | 99 | 100 | class GeoLocationField(models.CharField): 101 | """ 102 | A geographical point, specified by floating-point latitude and longitude 103 | coordinates. Often used to integrate with mapping sites like Google Maps. 104 | May also be used as ICBM coordinates. 105 | 106 | This is the georss:point element. In XML output, the coordinates are 107 | provided as the lat and lon attributes. See: http://georss.org/ 108 | 109 | Serializes to ','. Raises BadValueError if it's passed an invalid 110 | serialized string, or if lat and lon are not valid floating points in the 111 | ranges [-90, 90] and [-180, 180], respectively. 112 | """ 113 | 114 | description = "A geographical point, specified by floating-point latitude and longitude coordinates." 115 | 116 | def __init__(self, *args, **kwargs): 117 | kwargs["max_length"] = 100 118 | super(GeoLocationField, self).__init__(*args, **kwargs) 119 | 120 | def from_db_value(self, value, *args, **kwargs): 121 | return self.to_python(value) 122 | 123 | def to_python(self, value): 124 | if isinstance(value, GeoPt): 125 | return value 126 | return GeoPt(value) 127 | 128 | def get_prep_value(self, value): 129 | """prepare the value for database query""" 130 | if value is None: 131 | return None 132 | return force_str(self.to_python(value)) 133 | 134 | def value_to_string(self, obj): 135 | value = self.value_from_object(obj) 136 | return self.get_prep_value(value) 137 | -------------------------------------------------------------------------------- /settings.py: -------------------------------------------------------------------------------- 1 | # Django settings for google_maps project. 2 | 3 | DEBUG = True 4 | 5 | ADMINS = ( 6 | # ('Your Name', 'your_email@example.com'), 7 | ) 8 | 9 | MANAGERS = ADMINS 10 | 11 | DATABASES = { 12 | 'default': { 13 | 'ENGINE': 'django.db.backends.sqlite3', # Add 'postgresql_psycopg2', 'postgresql', 'mysql', 'sqlite3' or 'oracle'. 14 | 'NAME': 'sample_db', # Or path to database file if using sqlite3. 15 | 'USER': '', # Not used with sqlite3. 16 | 'PASSWORD': '', # Not used with sqlite3. 17 | 'HOST': '', # Set to empty string for localhost. Not used with sqlite3. 18 | 'PORT': '', # Set to empty string for default. Not used with sqlite3. 19 | } 20 | } 21 | 22 | # Local time zone for this installation. Choices can be found here: 23 | # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name 24 | # although not all choices may be available on all operating systems. 25 | # On Unix systems, a value of None will cause Django to use the same 26 | # timezone as the operating system. 27 | # If running in a Windows environment this must be set to the same as your 28 | # system time zone. 29 | TIME_ZONE = 'America/Chicago' 30 | 31 | # Language code for this installation. All choices can be found here: 32 | # http://www.i18nguy.com/unicode/language-identifiers.html 33 | LANGUAGE_CODE = 'en-us' 34 | 35 | SITE_ID = 1 36 | 37 | # If you set this to False, Django will make some optimizations so as not 38 | # to load the internationalization machinery. 39 | USE_I18N = True 40 | 41 | # If you set this to False, Django will not format dates, numbers and 42 | # calendars according to the current locale 43 | USE_L10N = True 44 | 45 | # Absolute filesystem path to the directory that will hold user-uploaded files. 46 | # Example: "/home/media/media.lawrence.com/media/" 47 | MEDIA_ROOT = '' 48 | 49 | # URL that handles the media served from MEDIA_ROOT. Make sure to use a 50 | # trailing slash. 51 | # Examples: "http://media.lawrence.com/media/", "http://example.com/media/" 52 | MEDIA_URL = '' 53 | 54 | # Absolute path to the directory static files should be collected to. 55 | # Don't put anything in this directory yourself; store your static files 56 | # in apps' "static/" subdirectories and in STATICFILES_DIRS. 57 | # Example: "/home/media/media.lawrence.com/static/" 58 | STATIC_ROOT = '' 59 | 60 | # URL prefix for static files. 61 | # Example: "http://media.lawrence.com/static/" 62 | STATIC_URL = '/static/' 63 | 64 | # URL prefix for admin static files -- CSS, JavaScript and images. 65 | # Make sure to use a trailing slash. 66 | # Examples: "http://foo.com/static/admin/", "/static/admin/". 67 | ADMIN_MEDIA_PREFIX = '/static/admin/' 68 | 69 | # Additional locations of static files 70 | STATICFILES_DIRS = ( 71 | # Put strings here, like "/home/html/static" or "C:/www/django/static". 72 | # Always use forward slashes, even on Windows. 73 | # Don't forget to use absolute paths, not relative paths. 74 | ) 75 | 76 | # List of finder classes that know how to find static files in 77 | # various locations. 78 | STATICFILES_FINDERS = ( 79 | 'django.contrib.staticfiles.finders.FileSystemFinder', 80 | 'django.contrib.staticfiles.finders.AppDirectoriesFinder', 81 | # 'django.contrib.staticfiles.finders.DefaultStorageFinder', 82 | ) 83 | 84 | TEMPLATES = [ 85 | { 86 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 87 | 'DIRS': [], 88 | 'APP_DIRS': True, 89 | 'OPTIONS': { 90 | 'context_processors': [ 91 | 'django.contrib.auth.context_processors.auth', 92 | 'django.contrib.messages.context_processors.messages', 93 | ] 94 | }, 95 | }, 96 | ] 97 | 98 | # Make this unique, and don't share it with anybody. 99 | SECRET_KEY = '%hi+eb@u)t)o_qk^#y&eje%*65ghba=1xulgk$_zfx5#&b3$g4' 100 | 101 | 102 | MIDDLEWARE = ( 103 | 'django.middleware.common.CommonMiddleware', 104 | 'django.contrib.sessions.middleware.SessionMiddleware', 105 | 'django.middleware.csrf.CsrfViewMiddleware', 106 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 107 | 'django.contrib.messages.middleware.MessageMiddleware', 108 | ) 109 | 110 | ROOT_URLCONF = 'urls' 111 | 112 | 113 | INSTALLED_APPS = ( 114 | 'django.contrib.auth', 115 | 'django.contrib.contenttypes', 116 | 'django.contrib.sessions', 117 | 'django.contrib.sites', 118 | 'django.contrib.messages', 119 | 'django.contrib.staticfiles', 120 | # Uncomment the next line to enable the admin: 121 | 'django.contrib.admin', 122 | # Uncomment the next line to enable admin documentation: 123 | # 'django.contrib.admindocs', 124 | 'django_google_maps', 125 | 'sample', 126 | 'django_google_maps.tests.test_app' 127 | ) 128 | 129 | # A sample logging configuration. The only tangible logging 130 | # performed by this configuration is to send an email to 131 | # the site admins on every HTTP 500 error. 132 | # See http://docs.djangoproject.com/en/dev/topics/logging for 133 | # more details on how to customize your logging configuration. 134 | LOGGING = { 135 | 'version': 1, 136 | 'disable_existing_loggers': False, 137 | 'handlers': { 138 | 'mail_admins': { 139 | 'level': 'ERROR', 140 | 'class': 'django.utils.log.AdminEmailHandler' 141 | } 142 | }, 143 | 'loggers': { 144 | 'django.request': { 145 | 'handlers': ['mail_admins'], 146 | 'level': 'ERROR', 147 | 'propagate': True, 148 | }, 149 | } 150 | } 151 | 152 | 153 | GOOGLE_MAPS_API_KEY = 'SAMPLE_KEY' 154 | -------------------------------------------------------------------------------- /django_google_maps/static/django_google_maps/js/google-maps-admin.js: -------------------------------------------------------------------------------- 1 | /* 2 | Integration for Google Maps in the django admin. 3 | 4 | How it works: 5 | 6 | You have an address field on the page. 7 | Enter an address and an on change event will update the map 8 | with the address. A marker will be placed at the address. 9 | If the user needs to move the marker, they can and the geolocation 10 | field will be updated. 11 | 12 | Only one marker will remain present on the map at a time. 13 | 14 | This script expects: 15 | 16 | 17 | 18 | 19 | 20 | 21 | */ 22 | 23 | function googleMapAdmin() { 24 | 25 | var autocomplete; 26 | var geocoder; 27 | var map; 28 | var marker; 29 | 30 | var geolocationId = 'id_geolocation'; 31 | var addressId = 'id_address'; 32 | var messageBoxId = 'map_message_box'; 33 | 34 | var self = { 35 | /** 36 | * Initializes the Google Map, Autocomplete, and sets up event listeners. 37 | */ 38 | initialize: function () { 39 | // Initialize Geocoder 40 | geocoder = new google.maps.Geocoder(); 41 | var lat = 0; 42 | var lng = 0; 43 | var zoom = 2; // Default to world view 44 | 45 | // Get existing location from the geolocation input field 46 | var existingLocation = self.getExistingLocation(); 47 | 48 | if (existingLocation) { 49 | lat = parseFloat(existingLocation[0]); // Ensure latitude is a number 50 | lng = parseFloat(existingLocation[1]); // Ensure longitude is a number 51 | zoom = 18; // Zoom in if a location already exists 52 | } 53 | 54 | // Create a LatLng object for the map center 55 | var latlng = {lat: lat, lng: lng}; 56 | var myOptions = { 57 | zoom: zoom, 58 | center: latlng, 59 | mapTypeId: self.getMapType(), 60 | streetViewControl: false, 61 | mapTypeControl: true, 62 | fullscreenControl: false, 63 | mapId: "dj-google-maps-admin" 64 | }; 65 | 66 | // Create the map instance 67 | map = new google.maps.Map(document.getElementById("map_canvas"), myOptions); 68 | 69 | // If an existing location is present, set a marker 70 | if (existingLocation) { 71 | self.setMarker(latlng); 72 | } 73 | 74 | // Initialize Google Places Autocomplete on the address input field 75 | autocomplete = new google.maps.places.Autocomplete( 76 | /** @type {!HTMLInputElement} */(document.getElementById(addressId)), 77 | self.getAutoCompleteOptions()); 78 | 79 | // Add listener for when a place is selected from the autocomplete suggestions 80 | // This triggers when the user presses enter or selects a suggestion 81 | autocomplete.addListener("place_changed", self.codeAddress); 82 | 83 | // Prevent the 'Enter' key from submitting the form when in the address field. 84 | // Instead, it should trigger the place_changed event for autocomplete. 85 | document.getElementById(addressId).addEventListener("keydown", function (e) { 86 | if (e.key === "Enter") { 87 | e.preventDefault(); 88 | return false; 89 | } 90 | }); 91 | }, 92 | 93 | /** 94 | * Determines the map type based on a 'data-map-type' attribute on the address input. 95 | * Falls back to 'hybrid' if not specified or invalid. 96 | * @returns {string} The map type string (e.g., 'roadmap', 'satellite'). 97 | */ 98 | getMapType: function () { 99 | // https://developers.google.com/maps/documentation/javascript/maptypes 100 | var addressInput = document.getElementById(addressId); 101 | var allowedTypes = ['roadmap', 'satellite', 'hybrid', 'terrain']; 102 | var mapType = addressInput.getAttribute('data-map-type'); 103 | 104 | if (mapType && allowedTypes.includes(mapType)) { 105 | return mapType; 106 | } 107 | 108 | return 'hybrid'; // Default to hybrid map type 109 | }, 110 | 111 | /** 112 | * Retrieves autocomplete options from a 'data-autocomplete-options' attribute. 113 | * Defaults to geocode type if not specified. 114 | * @returns {object} Autocomplete options object. 115 | */ 116 | getAutoCompleteOptions: function () { 117 | var addressInput = document.getElementById(addressId); 118 | var autocompleteOptions = addressInput.getAttribute('data-autocomplete-options'); 119 | 120 | if (!autocompleteOptions) { 121 | return { 122 | types: ['geocode'] 123 | }; 124 | } 125 | 126 | try { 127 | return JSON.parse(autocompleteOptions); 128 | } catch (e) { 129 | console.error("Error parsing data-autocomplete-options:", e); 130 | self.showMessage("Error: Invalid autocomplete options format. Using default.", 'error'); 131 | return {types: ['geocode']}; 132 | } 133 | }, 134 | 135 | /** 136 | * Retrieves existing latitude and longitude from the geolocation input field. 137 | * @returns {Array|undefined} An array [latitude, longitude] or undefined if empty. 138 | */ 139 | getExistingLocation: function () { 140 | var geolocationInput = document.getElementById(geolocationId).value; 141 | if (geolocationInput) { 142 | return geolocationInput.split(','); 143 | } 144 | return undefined; 145 | }, 146 | 147 | /** 148 | * Geocodes the address entered in the autocomplete field. 149 | * Updates the map and marker based on the geocoded location. 150 | */ 151 | codeAddress: function () { 152 | var place = autocomplete.getPlace(); 153 | 154 | // Checkifa place with geometry (location) was found by Autocomplete 155 | if (place.geometry && place.geometry.location) { 156 | self.updateWithCoordinates(place.geometry.location); 157 | } else if (place.name) { 158 | // If no geometry, but a place name exists, try to geocode it 159 | geocoder.geocode({'address': place.name}, function (results, status) { 160 | if (status === 'OK' && results.length > 0) { 161 | var latlng = results[0].geometry.location; 162 | self.updateWithCoordinates(latlng); 163 | } else if (status === 'ZERO_RESULTS') { 164 | self.showMessage("No results found for '" + place.name + "'.", 'warning'); 165 | } else { 166 | self.showMessage("Geocode was not successful for the following reason: " + status, 'error'); 167 | } 168 | }); 169 | } else { 170 | self.showMessage("Please enter a valid address.", 'warning'); 171 | } 172 | }, 173 | 174 | /** 175 | * Updates the map center, zoom, marker, and geolocation input with new coordinates. 176 | * @param {google.maps.LatLng} latlng - The new LatLng object. 177 | */ 178 | updateWithCoordinates: function (latlng) { 179 | map.setCenter(latlng); 180 | map.setZoom(18); 181 | self.setMarker(latlng); 182 | self.updateGeolocation(latlng); 183 | }, 184 | 185 | /** 186 | * Sets or updates the map marker at the given LatLng. 187 | * @param {google.maps.LatLng} latlng - The LatLng for the marker. 188 | */ 189 | setMarker: function (latlng) { 190 | if (marker) { 191 | self.updateMarker(latlng); 192 | } else { 193 | self.addMarker({'latlng': latlng, 'draggable': true}); 194 | } 195 | }, 196 | 197 | /** 198 | * Adds a new marker to the map. 199 | * @param {object} Options - Marker options, including latlng and draggable. 200 | */ 201 | addMarker: function (Options) { 202 | var draggable = Options.draggable || false; 203 | marker = new google.maps.marker.AdvancedMarkerElement({ 204 | map: map, 205 | position: Options.latlng, 206 | gmpDraggable: draggable 207 | }); 208 | 209 | if (draggable) { 210 | self.addMarkerDrag(marker); 211 | } 212 | }, 213 | 214 | /** 215 | * Adds a 'dragend' listener to the marker to update geolocation when dragged. 216 | */ 217 | addMarkerDrag: function (draggableMarker) { 218 | // Use the modern addListener method 219 | draggableMarker.addListener('dragend', function (event) { 220 | self.updateGeolocation(event.latLng); 221 | }); 222 | }, 223 | 224 | /** 225 | * Updates the position of the existing marker. 226 | * @param {google.maps.LatLng} latlng - The new LatLng for the marker. 227 | */ 228 | updateMarker: function (latlng) { 229 | marker.position = latlng; 230 | }, 231 | 232 | /** 233 | * Updates the geolocation input field with the new latitude and longitude. 234 | * Manually dispatches a 'change' event for compatibility with other scripts. 235 | * @param {google.maps.LatLng} latlng - The LatLng object to extract coordinates from. 236 | */ 237 | updateGeolocation: function (latlng) { 238 | document.getElementById(geolocationId).value = latlng.lat() + "," + latlng.lng(); 239 | 240 | // Manually trigger a change event on the geolocation input 241 | var event = new Event('change', {bubbles: true}); 242 | document.getElementById(geolocationId).dispatchEvent(event); 243 | }, 244 | 245 | /** 246 | * Displays a temporary message in the message box. 247 | * @param {string} message - The message to display. 248 | * @param {string} type - 'info', 'warning', or 'error' to apply styling. 249 | */ 250 | showMessage: function (message, type = 'info') { 251 | var messageBox = document.getElementById(messageBoxId); 252 | messageBox.textContent = message; 253 | 254 | // Clear previous styling classes 255 | messageBox.className = ''; 256 | messageBox.classList.add('rounded-md', 'p-3', 'text-sm', 'font-medium', 'transition-opacity', 'duration-300', 'ease-in-out'); 257 | 258 | // Apply type-specific styling 259 | if (type === 'error') { 260 | messageBox.classList.add('bg-red-100', 'text-red-800', 'border-red-400'); 261 | } else if (type === 'warning') { 262 | messageBox.classList.add('bg-yellow-100', 'text-yellow-800', 'border-yellow-400'); 263 | } else { // info 264 | messageBox.classList.add('bg-blue-100', 'text-blue-800', 'border-blue-400'); 265 | } 266 | 267 | messageBox.classList.add('show'); // Make it visible 268 | 269 | // Hide the message after 5 seconds 270 | setTimeout(function () { 271 | messageBox.classList.remove('show'); 272 | }, 5000); 273 | } 274 | }; 275 | 276 | return self; 277 | } 278 | 279 | 280 | async function initGoogleMap() { 281 | await google.maps.importLibrary("maps"); 282 | await google.maps.importLibrary("marker"); 283 | await google.maps.importLibrary("places"); 284 | 285 | var googlemap = googleMapAdmin(); 286 | googlemap.initialize(); 287 | } 288 | --------------------------------------------------------------------------------