Browse Source

Implementing store locator feature

Cory Sutyak 6 years ago
parent
commit
a85ddcc05f
30 changed files with 992 additions and 40 deletions
  1. 60 0
      DOCS.md
  2. 5 3
      coderedcms/admin_urls.py
  3. 0 6
      coderedcms/blocks/html_blocks.py
  4. 1 1
      coderedcms/blocks/layout_blocks.py
  5. 13 3
      coderedcms/forms.py
  6. 90 0
      coderedcms/importexport.py
  7. 33 0
      coderedcms/migrations/0004_auto_20181119_1507.py
  8. 260 5
      coderedcms/models/page_models.py
  9. 1 1
      coderedcms/models/snippet_models.py
  10. 16 0
      coderedcms/models/wagtailsettings_models.py
  11. 1 0
      coderedcms/project_template/project_name/settings/base.py
  12. 1 1
      coderedcms/schema.py
  13. 113 0
      coderedcms/static/css/codered-front.css
  14. 27 8
      coderedcms/static/js/codered-front.js
  15. 136 0
      coderedcms/static/js/codered-maps.js
  16. 1 1
      coderedcms/templates/coderedcms/blocks/google_map.html
  17. 10 0
      coderedcms/templates/coderedcms/includes/map_list_description.html
  18. 3 0
      coderedcms/templates/coderedcms/includes/map_pin_description.html
  19. 40 0
      coderedcms/templates/coderedcms/pages/location_index_page.html
  20. 42 0
      coderedcms/templates/coderedcms/pages/location_page.html
  21. 3 1
      coderedcms/templates/coderedcms/pages/web_page.html
  22. 11 0
      coderedcms/templates/wagtailimportexport/export_to_file.html
  23. 11 0
      coderedcms/templates/wagtailimportexport/import_from_api.html
  24. 33 0
      coderedcms/templates/wagtailimportexport/import_from_csv.html
  25. 11 0
      coderedcms/templates/wagtailimportexport/import_from_file.html
  26. 23 0
      coderedcms/templates/wagtailimportexport/index.html
  27. 1 1
      coderedcms/utils.py
  28. 41 6
      coderedcms/views.py
  29. 2 2
      coderedcms/wagtail_hooks.py
  30. 3 1
      setup.py

+ 60 - 0
DOCS.md

@@ -9,6 +9,7 @@ Table of Contents:
 * [Hooks](#hooks)
 * [Settings](#codered-cms-settings)
 * [Developing coderedcms](#developing-and-testing-codered-cms)
+* [Additional Features](#additional-features)
 
 
 
@@ -158,3 +159,62 @@ To build a publicly consumable pip package, run:
     python setup.py sdist bdist_wheel
 
 which will build a source distribution and a wheel in the `dist/` directory.
+
+
+### Additional Features
+
+#### Import/Export
+
+`wagtail-import-export` is included in the CMS.  You can find documentation for it [here](https://github.com/torchbox/wagtail-import-export).  In addition to the JSON import/export functionality that the package includes, we have added the ability to create pages by importing csv's.  In the csv each row will be a new page and each column header will correspond to an attribute of that page.  On the import csv page, you will select where you want the pages to live and what page type they should be created as.  A use case for this functionality would be if your site needs to add several hundred locations as pages.  These locations come from a csv dump from some report generating software.  Your csv could look something like this:
+```
+title       address         latitude    longitude
+Store 1     123 Street      20.909      -15.32
+Store 2     456 Avenue      34.223      87.2331
+...
+...
+```
+`title`, `address`, `latitude`, `longitude` are all fields on your Page model that you will be importing as.
+
+### Additional Page Types
+
+When you start a project, you will have a generated `models.py` file with some implementations of the CMS's base pages.  There exist additional base pages that we feel are useful, but not needed for most projects.  Below you can see basic, recommended implmentations of those base pages.
+
+#### Location
+```
+from django.utils.translation import ugettext_lazy as _
+from coderedcms.models import (
+    CoderedLocationIndexPage,
+    CoderedLocationPage,
+)
+
+class LocationPage(CoderedLocationPage):
+    """
+    A page that holds a location.  This could be a store, a restaurant, etc.
+    """
+    class Meta:
+        verbose_name = _('Location Page')
+
+    template = 'coderedcms/pages/location_page.html'
+
+    # Only allow LocationIndexPages above this page.
+    parent_page_types = ['website.LocationIndexPage']
+
+
+class LocationIndexPage(CoderedLocationIndexPage):
+    """
+    A page that holds a list of locations and displays them with a Google Map.
+    This does require a Google Maps API Key that can be defined in your 
+    wagtail settings.
+    """
+    class Meta:
+        verbose_name =_('Location Landing Page')
+
+    # Override to specify custom index ordering choice/default.
+    index_query_pagemodel = 'website.LocationPage'
+
+    # Only allow LocationPages beneath this page.
+    subpage_types = ['website.LocationPage']
+
+    template = 'coderedcms/pages/location_index_page.html'
+
+```

+ 5 - 3
coderedcms/admin_urls.py

@@ -1,10 +1,12 @@
 from django.conf.urls import include, url
+from wagtailimportexport import urls as wagtailimportexport_urls
 from wagtail.admin import urls as wagtailadmin_urls
-from coderedcms.views import clear_cache
-from coderedcms.settings import cr_settings
+from coderedcms.views import clear_cache, import_pages_from_csv_file
 
 
 urlpatterns = [
     url(r'^codered/clearcache$', clear_cache, name="clear_cache"),
+    url(r'^codered/import-export/import_from_csv/$', import_pages_from_csv_file, name="import_from_csv"),
     url(r'', include(wagtailadmin_urls)),
-]
+    url(r'', include(wagtailimportexport_urls)),
+]

+ 0 - 6
coderedcms/blocks/html_blocks.py

@@ -107,12 +107,6 @@ class EmbedGoogleMapBlock(BaseBlock):
         label=_('Search query'),
         help_text=_('Address or search term used to find your location on the map.'),
     )
-    api_key = blocks.CharBlock(
-        required=False,
-        max_length=255,
-        label=_('API key'),
-        help_text=_('Optional. Only required to use place ID and zoom features.')
-    )
     place_id = blocks.CharBlock(
         required=False,
         max_length=255,

+ 1 - 1
coderedcms/blocks/layout_blocks.py

@@ -9,7 +9,7 @@ from wagtail.images.blocks import ImageChooserBlock
 
 from coderedcms.settings import cr_settings
 
-from .base_blocks import BaseBlock, BaseLayoutBlock, CoderedAdvColumnSettings
+from .base_blocks import BaseLayoutBlock, CoderedAdvColumnSettings
 
 
 ### Level 1 layout blocks

+ 13 - 3
coderedcms/forms.py

@@ -3,17 +3,18 @@ Enhancements to wagtail.contrib.forms.
 """
 import csv
 import os
+import re
 from django import forms
 from django.core.exceptions import ValidationError
 from django.db import models
+from django.http import HttpResponse
 from django.utils.translation import ugettext_lazy as _
-from django.shortcuts import render
 from wagtail.contrib.forms.views import SubmissionsListView as WagtailSubmissionsListView
 from wagtail.contrib.forms.forms import FormBuilder
 from wagtail.contrib.forms.models import AbstractFormField
 
 from coderedcms.settings import cr_settings
-
+from coderedcms.utils import attempt_protected_media_value_conversion
 
 FORM_FIELD_CHOICES = (
     (_('Text'), (
@@ -130,7 +131,7 @@ class CoderedSubmissionsListView(WagtailSubmissionsListView):
         for data_row in context['data_rows']:
             modified_data_row = []
             for cell in data_row:
-                modified_cell = utils.attempt_protected_media_value_conversion(self.request, cell)
+                modified_cell = attempt_protected_media_value_conversion(self.request, cell)
                 modified_data_row.append(modified_cell)
 
             writer.writerow(modified_data_row)
@@ -156,3 +157,12 @@ class SearchForm(forms.Form):
         required=False,
         label=_('Page type'),
     )
+
+def get_page_model_choices():
+    """
+    Returns a list of tuples of all creatable Codered pages in the format of ("Custom Codered Page", "CustomCoderedPage")
+    """
+    from coderedcms.models import get_page_models
+    return (
+        (page.__name__, re.sub(r'((?<=[a-z])[A-Z]|(?<!\A)[A-Z](?=[a-z]))', r' \1', page.__name__)) for page in get_page_models() if page.is_creatable
+    )

+ 90 - 0
coderedcms/importexport.py

@@ -0,0 +1,90 @@
+import csv
+import copy
+
+from django import forms
+from django.apps import apps
+from django.contrib.contenttypes.models import ContentType
+from django.db import transaction
+
+from wagtail.core.models import Page
+
+from wagtailimportexport.forms import ImportFromFileForm
+from wagtailimportexport.importing import update_page_references
+
+from coderedcms.forms import get_page_model_choices
+
+class ImportPagesFromCSVFileForm(ImportFromFileForm):
+    page_type = forms.ChoiceField(choices=get_page_model_choices)
+
+@transaction.atomic()
+def import_pages(import_data, parent_page):
+    """
+    Overwrite of the wagtailimportexport `import_page` function to handle generic csvs.
+    The standard `import_pages` assumes that your pages will have a pk from the exported
+    json files.  It does not facilitate the idea that the pages you import will be
+    new pages.
+    """
+
+    pages_by_original_id = {}
+
+    # First create the base Page records; these contain no foreign keys, so this allows us to
+    # build a complete mapping from old IDs to new IDs before we go on to importing the
+    # specific page models, which may require us to rewrite page IDs within foreign keys / rich
+    # text / streamfields.
+    page_content_type = ContentType.objects.get_for_model(Page)
+
+    for page_record in import_data['pages']:
+        # build a base Page instance from the exported content (so that we pick up its title and other
+        # core attributes)
+        page = Page.from_serializable_data(page_record['content'])
+
+        # clear id and treebeard-related fields so that they get reassigned when we save via add_child
+        page.id = None
+        page.path = None
+        page.depth = None
+        page.numchild = 0
+        page.url_path = None
+        page.content_type = page_content_type
+        parent_page.add_child(instance=page)
+
+        # Custom Code to add the new pk back into the original page record.
+        page_record['content']['pk'] = page.pk
+
+        pages_by_original_id[page.id] = page
+
+    for page_record in import_data['pages']:
+        # Get the page model of the source page by app_label and model name
+        # The content type ID of the source page is not in general the same
+        # between the source and destination sites but the page model needs
+        # to exist on both.
+        # Raises LookupError exception if there is no matching model
+        model = apps.get_model(page_record['app_label'], page_record['model'])
+
+        specific_page = model.from_serializable_data(page_record['content'], check_fks=False, strict_fks=False)
+        base_page = pages_by_original_id[specific_page.id]
+        specific_page.page_ptr = base_page
+        specific_page.__dict__.update(base_page.__dict__)
+        specific_page.content_type = ContentType.objects.get_for_model(model)
+        update_page_references(specific_page, pages_by_original_id)
+        specific_page.save()
+
+    return len(import_data['pages'])
+
+def convert_csv_to_json(csv_file, page_type):
+    pages_json = {
+        "pages": []
+    }
+    default_page_data = {
+      "app_label": "website",
+      "content": {
+        "pk": None
+      },
+      "model": page_type
+    }
+
+    pages_csv_dict = csv.DictReader(csv_file)
+    for row in pages_csv_dict:
+        page_dict = copy.deepcopy(default_page_data)
+        page_dict['content'].update(row)
+        pages_json['pages'].append(page_dict)
+    return pages_json

+ 33 - 0
coderedcms/migrations/0004_auto_20181119_1507.py

@@ -0,0 +1,33 @@
+# Generated by Django 2.0.9 on 2018-11-19 20:07
+
+import coderedcms.blocks.base_blocks
+from django.db import migrations, models
+import django.db.models.deletion
+import wagtail.contrib.table_block.blocks
+import wagtail.core.blocks
+import wagtail.core.fields
+import wagtail.documents.blocks
+import wagtail.images.blocks
+import wagtail.snippets.blocks
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('wagtailcore', '0040_page_draft_title'),
+        ('coderedcms', '0003_auto_20180912_1632'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='GoogleApiSettings',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('google_maps_api_key', models.CharField(blank=True, help_text='The API Key used for Google Maps.', max_length=255, verbose_name='Google Maps API Key')),
+                ('site', models.OneToOneField(editable=False, on_delete=django.db.models.deletion.CASCADE, to='wagtailcore.Site')),
+            ],
+            options={
+                'verbose_name': 'Google API Settings',
+            },
+        ),
+    ]

+ 260 - 5
coderedcms/models/page_models.py

@@ -4,18 +4,26 @@ Base and abstract pages used in CodeRed CMS.
 
 import json
 import os
+
+import geocoder
+
 from django.conf import settings
 from django.core.files.uploadedfile import InMemoryUploadedFile, TemporaryUploadedFile
 from django.core.files.storage import FileSystemStorage
 from django.core.mail import send_mail, EmailMessage
 from django.core.paginator import Paginator
 from django.core.serializers.json import DjangoJSONEncoder
+from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
+from django.http import JsonResponse
 from django.shortcuts import render, redirect
 from django.template import Context, Template
+from django.template.loader import render_to_string
 from django.utils import timezone
 from django.utils.html import strip_tags
+from django.utils.safestring import mark_safe
 from django.utils.translation import ugettext_lazy as _
+from wagtail.admin import messages
 from wagtail.admin.edit_handlers import (
     HelpPanel,
     FieldPanel,
@@ -43,7 +51,7 @@ from coderedcms.blocks import (
     OpenHoursBlock,
     StructuredDataActionBlock)
 from coderedcms.forms import CoderedFormBuilder, CoderedSubmissionsListView
-from coderedcms.models.wagtailsettings_models import GeneralSettings, LayoutSettings, SeoSettings
+from coderedcms.models.wagtailsettings_models import GeneralSettings, LayoutSettings, SeoSettings, GoogleApiSettings
 from coderedcms.settings import cr_settings
 
 
@@ -82,7 +90,6 @@ class CoderedPage(Page, metaclass=CoderedPageMeta):
     """
     class Meta:
         verbose_name = _('CodeRed Page')
-
     # Do not allow this page type to be created in wagtail admin
     is_creatable = False
 
@@ -94,7 +101,6 @@ class CoderedPage(Page, metaclass=CoderedPageMeta):
     # ajax_template = ''
     # search_template = ''
 
-
     ###############
     # Content fields
     ###############
@@ -107,7 +113,6 @@ class CoderedPage(Page, metaclass=CoderedPageMeta):
         verbose_name=_('Cover image'),
     )
 
-
     ###############
     # Index fields
     ###############
@@ -498,7 +503,6 @@ class CoderedPage(Page, metaclass=CoderedPageMeta):
         context['content_walls'] = self.get_content_walls(check_child_setting=False)
         return context
 
-
 ###############################################################################
 # Abstract pages providing pre-built common website functionality, suitable for subclassing.
 # These are abstract so subclasses can override fields if desired.
@@ -545,6 +549,18 @@ class CoderedWebPage(CoderedPage):
         return body[:200] + "..."
 
 
+    @property
+    def page_ptr(self):
+        """
+        Overwrite of `page_ptr` to make it compatible with wagtailimportexport.
+        """
+        return self.base_page_ptr
+
+    @page_ptr.setter
+    def page_ptr(self, value):
+        self.base_page_ptr = value    
+
+
 class CoderedArticlePage(CoderedWebPage):
     """
     Article, suitable for news or blog content.
@@ -1053,3 +1069,242 @@ class CoderedFormPage(CoderedWebPage):
             return self.render_landing_page(request)
 
         return super().serve_preview(request, mode)
+
+
+class CoderedLocationPage(CoderedWebPage):
+    """
+    Location, suitable for store locations or help centers.
+    """
+    class Meta:
+        verbose_name = _('CodeRed Location')
+        abstract = True
+
+    template = 'coderedcms/pages/location_page.html'
+
+    # Override body to provide simpler content
+    body = StreamField(CONTENT_STREAMBLOCKS, null=True, blank=True)
+
+    address = models.TextField(
+        blank=True,
+        verbose_name=_("Address")
+    )
+    latitude = models.FloatField(
+        blank=True,
+        null=True,
+        verbose_name=_("Latitude")
+    )
+    longitude = models.FloatField(
+        blank=True,
+        null=True,
+        verbose_name=_("Longitude")
+    )
+    auto_update_latlng = models.BooleanField(
+        default=True,
+        verbose_name=_("Auto Update Latitude and Longitude"),
+        help_text=_("If checked, automatically update the latitude and longitude when the address is updated.")
+    )
+    map_title = models.CharField(
+        blank=True,
+        max_length=255,
+        verbose_name=_("Map Title"),
+        help_text=_("If this is filled out, this is the title that will be used on the map.")
+    )
+    map_description = models.CharField(
+        blank=True,
+        max_length=255,
+        verbose_name=_("Map Description"),
+        help_text=_("If this is filled out, this is the description that will be used on the map.")
+    )
+    website = models.TextField(
+        blank=True,
+        verbose_name=_("Website")
+    )
+    phone_number = models.CharField(
+        blank=True,
+        max_length=255,
+        verbose_name=_("Phone Number")
+    )
+
+    content_panels = (
+        CoderedWebPage.content_panels[:1] + 
+        [
+            FieldPanel('address'),
+            FieldPanel('website'),
+            FieldPanel('phone_number'),
+        ] +
+        CoderedWebPage.content_panels[1:]
+    )
+
+    layout_panels = (
+        CoderedWebPage.layout_panels +
+        [
+            MultiFieldPanel(
+                [
+                    FieldPanel('map_title'),
+                    FieldPanel('map_description'),
+                ],
+                heading=_('Map Layout')
+            ),
+        ]
+    )
+
+    settings_panels = (
+        CoderedWebPage.settings_panels + 
+        [
+            MultiFieldPanel(
+                [
+                    FieldPanel('auto_update_latlng'),
+                    FieldPanel('latitude'),
+                    FieldPanel('longitude'),
+                ],
+                heading=_("Location Settings")
+            ),
+        ]
+    )
+
+    @property
+    def geojson_name(self):
+        return self.map_title or self.title
+
+    @property
+    def geojson_description(self):
+        return self.map_description
+
+    @property
+    def render_pin_description(self):
+        return render_to_string(
+            'coderedcms/includes/map_pin_description.html',
+            {
+                'page': self
+            }
+        )
+
+    @property
+    def render_list_description(self):
+        return render_to_string(
+            'coderedcms/includes/map_list_description.html',
+            {
+                'page': self
+            }
+        )
+    
+    def to_geojson(self):
+        return {
+            "type": "Feature",
+            "geometry":{
+                "type": "Point",
+                "coordinates": [self.longitude, self.latitude]
+            },
+            "properties":{
+                "list_description": self.render_list_description,
+                "pin_description": self.render_pin_description
+            }
+        }
+
+    def save(self, *args, **kwargs):
+        if self.auto_update_latlng and GoogleApiSettings.for_site(Site.objects.get(is_default_site=True)).google_maps_api_key:
+            try:
+                g = geocoder.google(self.address, key=GoogleApiSettings.for_site(Site.objects.get(is_default_site=True)).google_maps_api_key)
+                self.latitude = g.latlng[0]
+                self.longitude = g.latlng[1]
+            except TypeError:
+                """Raised if google denied the request"""
+                pass
+
+        return super(CoderedLocationPage, self).save(*args, **kwargs)
+
+
+    def get_context(self, request, *args, **kwargs):
+        context = super().get_context(request)
+        context['google_api_key'] = GoogleApiSettings.for_site(Site.objects.get(is_default_site=True)).google_maps_api_key
+        return context
+
+
+class CoderedLocationIndexPage(CoderedWebPage):
+    """
+    Shows a map view of the children CoderedLocationPage.
+    """
+    class Meta:
+        verbose_name = _('CodeRed Location Index Page')
+        abstract = True
+
+    template = 'coderedcms/pages/location_index_page.html'
+
+    index_show_subpages_default = True
+
+    center_latitude = models.FloatField(
+        null=True,
+        blank=True, 
+        help_text=_('The default latitude you want the map set to.'),
+        default=0
+    )
+    center_longitude = models.FloatField(
+        null=True,
+        blank=True, 
+        help_text=_('The default longitude you want the map set to.'),
+        default=0
+    )
+    zoom = models.IntegerField(
+        default=8,
+        validators=[
+            MaxValueValidator(20),
+            MinValueValidator(1),
+        ],
+        help_text=_('Requires API key to use zoom. 1: World, 5: Landmass/continent, 10: City, 15: Streets, 20: Buildings')
+    )
+
+    layout_panels = (
+        CoderedWebPage.layout_panels +
+        [
+            MultiFieldPanel(
+                [
+                    FieldPanel('center_latitude'),
+                    FieldPanel('center_longitude'),
+                    FieldPanel('zoom'),
+                ],
+                heading=_('Map Display')
+            ),
+        ]
+    )
+        
+    def geojson_data(self, viewport=None):
+        """
+        function that will return all locations under this index as geoJSON compliant data.
+        It is filtered by a latitude/longitude viewport if given.
+
+        viewport is a string in the format of :
+        'southwest.latitude,southwest.longitude|northeast.latitude,northeast.longitude'
+
+        An example viewport that covers Cleveland, OH would look like this:
+        '41.354912150983964,-81.95331736661791|41.663427748126935,-81.45206614591478'
+        """
+        qs = self.get_index_children().live()
+
+        if viewport:
+            southwest, northeast = viewport.split('|')
+            southwest = [float(x) for x in southwest.split(',')]
+            northeast = [float(x) for x in northeast.split(',')]
+
+            qs = qs.filter(latitude__gte=southwest[0], latitude__lte=northeast[0], longitude__gte=southwest[1], longitude__lte=northeast[1])
+
+        return {
+            "type": "FeatureCollection",
+            "features": [
+                location.to_geojson() for location in qs
+            ]
+        }
+
+    def serve(self, request, *args, **kwargs):
+        data_format = request.GET.get('data-format', None)
+        if data_format == 'geojson':
+            return self.serve_geojson(request, *args, **kwargs)
+        return super().serve(request, *args, **kwargs)
+
+    def serve_geojson(self, request, *args, **kwargs):
+        viewport = request.GET.get('viewport', None)
+        return JsonResponse(self.geojson_data(viewport=viewport))
+
+    def get_context(self, request, *args, **kwargs):
+        context = super().get_context(request)
+        context['google_api_key'] = GoogleApiSettings.for_site(Site.objects.get(is_default_site=True)).google_maps_api_key
+        return context

+ 1 - 1
coderedcms/models/snippet_models.py

@@ -312,4 +312,4 @@ class CoderedEmail(ClusterableModel):
         ])
 
     def __str__(self):
-        return self.subject
+        return self.subject

+ 16 - 0
coderedcms/models/wagtailsettings_models.py

@@ -368,3 +368,19 @@ class SeoSettings(BaseSetting):
             heading=_('Search Engine Optimization')
         )
     ]
+
+
+@register_setting(icon='fa-puzzle-piece')
+class GoogleApiSettings(BaseSetting):
+    """
+    Settings for Google API services..
+    """
+    class Meta:
+        verbose_name = _('Google API Settings')
+
+    google_maps_api_key = models.CharField(
+        blank=True,
+        max_length=255,
+        verbose_name=_('Google Maps API Key'),
+        help_text=_('The API Key used for Google Maps.')
+    )

+ 1 - 0
coderedcms/project_template/project_name/settings/base.py

@@ -49,6 +49,7 @@ INSTALLED_APPS = [
     'wagtail.contrib.modeladmin',
     'wagtail.contrib.table_block',
     'wagtail.admin',
+    'wagtailimportexport',
 
     # Django
     'django.contrib.admin',

+ 1 - 1
coderedcms/schema.py

@@ -162,4 +162,4 @@ SCHEMA_RESULT_CHOICES = (
     ('ReservationPackage', 'ReservationPackage'),
     ('TaxiReservation', 'TaxiReservation'),
     ('TrainReservation','TrainReservation'),
-)
+)

+ 113 - 0
coderedcms/static/css/codered-front.css

@@ -328,3 +328,116 @@ Pygments source code formatting.
 .pygments .vi { color: #3333bb } /* Name.Variable.Instance */
 .pygments .vm { color: #336699 } /* Name.Variable.Magic */
 .pygments .il { color: #0000DD; font-weight: bold } /* Literal.Number.Integer.Long */
+
+
+/************
+
+GOOGLE MAPS
+
+************/
+
+.map-container{
+    min-height: 600px;
+    width: 100%;
+}
+
+
+#infowindow-content .title {
+    font-weight: bold;
+}
+
+#infowindow-content {
+    display: none;
+}
+
+#map #infowindow-content {
+    display: inline;
+}
+
+.pac-card {
+    margin: 10px 10px 0 0;
+    border-radius: 2px 0 0 2px;
+    box-sizing: border-box;
+    -moz-box-sizing: border-box;
+    outline: none;
+    box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
+    background-color: #fff;
+    font-family: Roboto;
+}
+
+#pac-container {
+    padding-bottom: 12px;
+    margin-right: 12px;
+}
+
+.pac-controls {
+    display: inline-block;
+    padding: 5px 11px;
+}
+
+.pac-controls label {
+    font-family: Roboto;
+    font-size: 13px;
+    font-weight: 300;
+}
+
+#pac-input {
+    background-color: #fff;
+    font-family: Roboto;
+    font-size: 16px;
+    font-weight: 300;
+    margin-left: 12px;
+    line-height: 34px;
+    text-overflow: ellipsis;
+    width: 200px;
+    top: 10px !important;
+    border: 2px solid #fff;
+    border-radius: 3px;
+    box-shadow: rgba(0, 0, 0, 0.3) 0 2px 2px;
+    padding-left: 5px;
+    padding-right: 5px;
+}
+
+#pac-input:focus {
+    border-color: #4d90fe;
+}
+
+#title {
+    color: #fff;
+    background-color: #4d90fe;
+    font-size: 25px;
+    font-weight: 500;
+    padding: 6px 12px;
+}
+#target {
+    width: 345px;
+}
+
+.address-card .card-body p{
+    margin-bottom: 0;
+}
+.address-card{
+    margin: 0 auto;
+    font-size:1.4em;
+}
+
+.map-button{
+    background-color:white;
+    color: black;
+    border-radius: 1px;
+    z-index:999;
+}
+
+
+@media(min-width: 768px){
+    .map-button{
+        position: absolute;
+        top: 13px;
+        left: 430px;
+    }
+}
+
+#LocationList{
+    max-height: 600px;
+    overflow-y: scroll;
+}

+ 27 - 8
coderedcms/static/js/codered-front.js

@@ -21,6 +21,10 @@ libs = {
         url: "https://cdnjs.cloudflare.com/ajax/libs/pickadate.js/3.5.6/compressed/picker.time.js",
         integrity: "sha256-vFMKre5X5oQN63N+oJU9cJzn22opMuJ+G9FWChlH5n8=",
         head: '<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/pickadate.js/3.5.6/compressed/themes/default.time.css" integrity="sha256-0GwWH1zJVNiu4u+bL27FHEpI0wjV0hZ4nSSRM2HmpK8=" crossorigin="anonymous" />'
+    },
+    coderedmaps: {
+        url: "/static/js/codered-maps.js",
+        integrity: "",
     }
 }
 
@@ -29,13 +33,15 @@ function load_script(lib, success) {
     if(lib.head) {
         $('head').append(lib.head);
     }
-    $.ajax({
-        url: lib.url,
-        dataType: "script",
-        integrity: lib.integrity,
-        crossorigin: "anonymous",
-        success: success
-    });
+    if(lib.url){
+        $.ajax({
+            url: lib.url,
+            dataType: "script",
+            integrity: lib.integrity,
+            crossorigin: "anonymous",
+            success: success
+        });
+    }
 }
 
 
@@ -88,7 +94,20 @@ $(document).ready(function()
             }
         });
     }
-
+    if ($('#cr-map').length > 0) {
+        load_script(libs.coderedmaps, function() {
+            $.ajax({
+                url: 'https://maps.googleapis.com/maps/api/js',
+                type: "get",
+                dataType: "script",
+                data: {
+                    'key': $("#cr-map").data( "key" ),
+                    'callback': $("#cr-map").data( "callback" ),
+                    'libraries': $("#cr-map").data( "libraries" ),
+                }
+            });
+        });
+    }
 
     /*** Lightbox ***/
     $('.lightbox-preview').on('click', function(event) {

+ 136 - 0
coderedcms/static/js/codered-maps.js

@@ -0,0 +1,136 @@
+  // Initialize the map on the gooogle maps api js callback.
+
+  function initMap() {
+    // Set defaults
+    const map = new google.maps.Map(document.querySelector('#cr-map'), {
+      zoom: parseInt($("#cr-map").data( "zoom" )),
+      center: {
+        lat: parseFloat($("#cr-map").data( "latitude" )),
+        lng: parseFloat($("#cr-map").data( "longitude" )),
+      },
+      mapTypeControl : $("#cr-map").data( "map-type-control"),
+      streetViewControl: $("#cr-map").data( "street-view-control"),
+    });
+    // Create an infowindow object.
+    var infowindow = new google.maps.InfoWindow({  });
+
+    if (navigator.geolocation) {
+          var currentLocationControlDiv = document.createElement('div');
+          var currentLocation = new CurrentLocationControl(currentLocationControlDiv, map);
+
+          currentLocationControlDiv.index = 1;
+          map.controls[google.maps.ControlPosition.TOP_LEFT].push(currentLocationControlDiv);
+    }
+
+
+    // Listener to update the map markers when the map is idling.
+    google.maps.event.addListener(map, 'idle', () => {
+      const sw = map.getBounds().getSouthWest();
+      const ne = map.getBounds().getNorthEast();
+      let locationDataFeatures = [];
+      map.data.loadGeoJson(
+        $("#cr-map").data( "geojson-url" ) + `&viewport=${sw.lat()},${sw.lng()}|${ne.lat()},${ne.lng()}`,
+        null,
+        features => {
+          locationDataFeatures.forEach(dataFeature => {
+            map.data.remove(dataFeature);
+          });
+          locationDataFeatures = features;
+          if ($("#cr-map").data( "show-list" ) == "True"){
+            updateList(locationDataFeatures);
+          }
+        }
+      );
+    });
+
+    // Listener to update the info window when a marker is clicked.
+    map.data.addListener('click', ev => {
+      const f = ev.feature;
+      infowindow.setContent(f.getProperty('pin_description'));
+      infowindow.setPosition(f.getGeometry().get());
+      infowindow.setOptions({
+        pixelOffset: new google.maps.Size(0, -30)
+      });
+      infowindow.open(map);
+    });
+
+
+    // Logic to create a search box and move the map on successful search.
+
+    if ($("#cr-map").data( "show-search" ) == "True"){
+      var input = document.getElementById('pac-input');
+      var searchBox = new google.maps.places.SearchBox(input);
+      map.controls[google.maps.ControlPosition.TOP_LEFT].push(input);
+      map.addListener('bounds_changed', function() {
+              searchBox.setBounds(map.getBounds());
+            });
+      searchBox.addListener('places_changed', function() {
+        var places = searchBox.getPlaces();
+        if (places.length == 0) {
+          return;
+        }
+        // For each place, get the icon, name and location.
+        var bounds = new google.maps.LatLngBounds();
+        places.forEach(function(place) {
+          if (!place.geometry) {
+            return;
+          }
+          if (place.geometry.viewport) {
+            // Only geocodes have viewport.
+            bounds.union(place.geometry.viewport);
+          } else {
+            bounds.extend(place.geometry.location);
+          }
+        });
+        map.fitBounds(bounds);
+      });
+    }
+
+  // Updates the list to the side of the map with markers that are in the viewport.
+  function updateList(features) {
+      new_html = "";
+      if(features.length == 0){
+        new_html = $("#cr-map").data( "empty-state" );
+      } else {
+        for (i=0; i < features.length; i++){
+            feature = features[i];
+            new_html += feature.getProperty('list_description');
+        }
+      }
+      $("#LocationList").html(new_html);
+    }
+  }
+
+  function CurrentLocationControl(controlDiv, map){
+    var controlUI = document.createElement('div');
+      controlUI.style.backgroundColor = '#fff';
+      controlUI.style.border = '2px solid #fff';
+      controlUI.style.borderRadius = '3px';
+      controlUI.style.boxShadow = '0 2px 2px rgba(0,0,0,.3)';
+      controlUI.style.cursor = 'pointer';
+      controlUI.style.marginTop = '10px'
+      controlUI.style.marginBottom = '22px';
+      controlUI.style.marginLeft = '10px';
+      controlUI.style.textAlign = 'center';
+      controlUI.title = 'Near Me';
+      controlDiv.appendChild(controlUI);
+
+      // Set CSS for the control interior.
+      var controlText = document.createElement('div');
+      controlText.style.color = 'rgb(25,25,25)';
+      controlText.style.fontFamily = 'Roboto,Arial,sans-serif';
+      controlText.style.fontSize = '16px';
+      controlText.style.lineHeight = '36px';
+      controlText.style.paddingLeft = '5px';
+      controlText.style.paddingRight = '5px';
+      controlText.innerHTML = 'Near Me';
+      controlUI.appendChild(controlText);
+
+      // Setup the click event listeners: simply set the map to Chicago.
+      controlUI.addEventListener('click', function() {
+          navigator.geolocation.getCurrentPosition(function (position){
+            currentPosition = new google.maps.LatLng(position.coords.latitude, position.coords.longitude);
+            map.setCenter(currentPosition);
+          });
+      });
+    }

+ 1 - 1
coderedcms/templates/coderedcms/blocks/google_map.html

@@ -1,7 +1,7 @@
 <div class="embed-responsive embed-responsive-16by9 {{self.settings.custom_css_clas}}"
 {% if self.custom_css_id %}id="{{self.settings.custom_css_id}}"{% endif %}>
 	{% if self.place_id %}
-		<iframe class="embed-responsive-item" width="100%" style="border:0" src="https://www.google.com/maps/embed/v1/place?q=place_id:{{ self.place_id }}&zoom={{ self.map_zoom_level }}&key={{ self.api_key }}" allowfullscreen></iframe>
+		<iframe class="embed-responsive-item" width="100%" style="border:0" src="https://www.google.com/maps/embed/v1/place?q=place_id:{{ self.place_id }}&zoom={{ self.map_zoom_level }}&key={{ settings.GoogleApiSettings.google_maps_api_key}}" allowfullscreen></iframe>
 	{% else %}
 		<iframe class="embed-responsive-item" width="100%" style="border:0" src="https://maps.google.com/maps?q={{ self.search }}&output=embed" allowfullscreen></iframe>
 	{% endif %}

+ 10 - 0
coderedcms/templates/coderedcms/includes/map_list_description.html

@@ -0,0 +1,10 @@
+<div class='list-group-item flex-column align-items'>
+    <div class='d-flex w-100 justify-content-between'>
+        <a href='{{ page.url }}'><b class='mb-1'>{{ page.geojson_name }}</b></a>
+    </div>
+    <small>{{ page.address }}</small>
+    {% if description %}
+    <br />
+    <small>{{ page.description }}</small>
+    {% endif %}
+</div>

+ 3 - 0
coderedcms/templates/coderedcms/includes/map_pin_description.html

@@ -0,0 +1,3 @@
+<b>{{ page.geojson_name }}</b>
+<p>{{ page.address }}</p>
+<p><a href='{{ page.url }}'>View Location</a></p>

+ 40 - 0
coderedcms/templates/coderedcms/pages/location_index_page.html

@@ -0,0 +1,40 @@
+{% extends "coderedcms/pages/web_page.html" %}
+
+{% load wagtailcore_tags static %}
+
+{% block content_body %}
+{% include_block page.body with settings=settings %}
+<div class="container">
+  <div class="row">
+      {% block map %}
+          <div class="col-md-8 col-xs-12 order-md-12 mb-5">
+          <input id="pac-input" class="controls" type="text" placeholder="Enter your location">
+            <div class="map-container" 
+            id="cr-map" 
+            data-zoom="{{ page.zoom }}" 
+            data-latitude="{{ page.center_latitude }}" 
+            data-longitude="{{ page.center_longitude }}" 
+            data-geojson-url="{{ request.path }}?data-format=geojson"
+            data-key="{{ google_api_key }}"
+            data-callback="initMap"
+            data-libraries="places"
+            data-show-list="True"
+            data-show-search="True"
+            data-empty-state="There are no locations in this area.  Try zooming out or moving the map to find a location."
+            data-map-type-control=false>
+            </div>
+          </div>
+      {% endblock %}
+      {% block map_list %}
+        <div class="col-md-4 col-xs-12 order-md-1 mb-5">
+          <div class="list-group" id="LocationList">
+              {% for item in geojson_data.features %}
+                {{ item.render_list_description }}
+              {% endfor %}
+          </div>
+        </div>
+      {% endblock %}
+  </div>
+</div>
+{% endblock %}
+

+ 42 - 0
coderedcms/templates/coderedcms/pages/location_page.html

@@ -0,0 +1,42 @@
+{% extends "coderedcms/pages/web_page.html" %}
+{% load wagtailcore_tags wagtailimages_tags coderedcms_tags %}
+
+{% block content_body %}
+<div class="container">
+    <div class="row">
+        {% if page.address or page.phone_number or page.website %}
+        <div class="card address-card text-center">
+          <div class="card-body">
+            {% if page.address %}
+            <p>
+            {{ page.address|linebreaks }}
+            </p>
+            {% endif %}
+            {% if page.phone_number %}
+            <p>
+            {{ page.phone_number }}
+            </p>
+            {% endif %}
+            {% if page.website %}
+            <p>
+                <a href="{{ page.website }}">Website</a>
+            </p>
+            {% endif %}
+          </div>
+        </div>
+        {% endif %}
+    </div>
+    <div class="row mb-5">
+        {% include_block page.body with settings=settings %}
+    </div>
+    {% block map %}
+    <div class="row mb-5">
+        {% if page.address %}
+        <div class="embed-responsive embed-responsive-16by9 mb-5">
+            <iframe class="embed-responsive-item" width="100%" style="border:0" src="https://maps.google.com/maps?q={{ page.address }}&output=embed" allowfullscreen></iframe>
+        </div>
+    {% endif %}
+    </div>
+    {% endblock %}
+</div>
+{% endblock %}

+ 3 - 1
coderedcms/templates/coderedcms/pages/web_page.html

@@ -17,5 +17,7 @@
       <h1>{{page.title}}</h1>
     </div>
   {% endif %}
-  {% include_block page.body with settings=settings %}
+  {% block content_body %}
+    {% include_block page.body with settings=settings %}
+  {% endblock %}
 {% endblock %}

+ 11 - 0
coderedcms/templates/wagtailimportexport/export_to_file.html

@@ -0,0 +1,11 @@
+{% extends "wagtailimportexport/export_to_file.html" %}
+
+{% block css %}
+    {{ block.super }}
+    {{ form.media.css }}
+{% endblock %}
+
+{% block js %}
+    {{ block.super }}
+    {{ form.media.js }}
+{% endblock %}

+ 11 - 0
coderedcms/templates/wagtailimportexport/import_from_api.html

@@ -0,0 +1,11 @@
+{% extends "wagtailimportexport/import_from_api.html" %}
+
+{% block css %}
+    {{ block.super }}
+    {{ form.media.css }}
+{% endblock %}
+
+{% block js %}
+    {{ block.super }}
+    {{ form.media.js }}
+{% endblock %}

+ 33 - 0
coderedcms/templates/wagtailimportexport/import_from_csv.html

@@ -0,0 +1,33 @@
+{% extends "wagtailadmin/base.html" %}
+{% load i18n %}
+{% block titletag %}{% blocktrans %}Import pages{% endblocktrans %}{% endblock %}
+{% block css %}
+    {{ block.super }}
+    {{ form.media.css }}
+{% endblock %}
+{% block js %}
+    {{ block.super }}
+    {{ form.media.js }}
+{% endblock %}
+{% block content %}
+    {% trans "Import pages" as title_str %}
+    {% include "wagtailadmin/shared/header.html" with title=title_str icon="download" %}
+
+    <div class="nice-padding">
+        <form action="{% url 'import_from_csv' %}" enctype="multipart/form-data" method="POST" novalidate>
+            {% csrf_token %}
+            <ul class="fields">
+                {% for field in form %}
+                    {% include "wagtailadmin/shared/field_as_li.html" %}
+                {% endfor %}
+            </ul>
+
+            <input type="submit" value="{% trans 'Import' %}" class="button">
+        </form>
+    </div>
+{% endblock %}
+
+{% block extra_js %}
+    {{ block.super }}
+    {% include "wagtailadmin/pages/_editor_js.html" %}
+{% endblock %}

+ 11 - 0
coderedcms/templates/wagtailimportexport/import_from_file.html

@@ -0,0 +1,11 @@
+{% extends "wagtailimportexport/import_from_file.html" %}
+
+{% block css %}
+    {{ block.super }}
+    {{ form.media.css }}
+{% endblock %}
+
+{% block js %}
+    {{ block.super }}
+    {{ form.media.js }}
+{% endblock %}

+ 23 - 0
coderedcms/templates/wagtailimportexport/index.html

@@ -0,0 +1,23 @@
+{% extends "wagtailadmin/base.html" %}
+{% load i18n %}
+{% block titletag %}{% blocktrans %}Import / export pages{% endblocktrans %}{% endblock %}
+{% block content %}
+    {% trans "Import / export pages" as title_str %}
+    {% include "wagtailadmin/shared/header.html" with title=title_str icon="download" %}
+
+    <div class="nice-padding">
+        <h3>JSON</h3>
+        <ul>
+            <li><a href="{% url 'wagtailimportexport_admin:import_from_file' %}">{% trans "Import from JSON file" %}</a></li>
+            <li><a href="{% url 'wagtailimportexport_admin:export_to_file' %}">{% trans "Export to JSON file" %}</a></li>
+        </ul>
+        <h3>CSV</h3>
+        <ul>
+            <li><a href="{% url 'import_from_csv' %}">{% trans "Import from CSV file" %}</a></li>
+        </ul>
+        <h3>API</h3>
+        <ul>
+            <li><a href="{% url 'wagtailimportexport_admin:import_from_api' %}">{% trans "Import from API" %}</a></li>
+        </ul>
+    </div>
+{% endblock %}

+ 1 - 1
coderedcms/utils.py

@@ -96,4 +96,4 @@ def seconds_to_readable(seconds):
         pretty_time += ' {0} {1}'.format(str(mins), _('minutes') if mins > 1  else _('minute'))
     if secs > 0:
         pretty_time += ' {0} {1}'.format(str(secs), _('seconds') if secs > 1  else _('second'))
-    return pretty_time
+    return pretty_time

+ 41 - 6
coderedcms/views.py

@@ -1,22 +1,25 @@
-import os
 import mimetypes
+import os
+
 from itertools import chain
 
 from django.contrib.auth.decorators import login_required
 from django.contrib.contenttypes.models import ContentType
 from django.core.paginator import Paginator
 from django.http import Http404, HttpResponse
-from django.shortcuts import render
-from wagtail.core.models import Page
+from django.shortcuts import redirect, render
+from django.utils.translation import ungettext, ugettext_lazy as _
+
+from wagtail.admin import messages
 from wagtail.search.backends import db, get_search_backend
 from wagtail.search.models import Query
 
 from coderedcms import utils
 from coderedcms.forms import SearchForm
+from coderedcms.importexport import convert_csv_to_json, import_pages, ImportPagesFromCSVFileForm
 from coderedcms.models import CoderedPage, get_page_models, GeneralSettings
 from coderedcms.settings import cr_settings
 
-
 def search(request):
     """
     Searches pages across the entire site.
@@ -107,8 +110,7 @@ def serve_protected_file(request, path):
             response["Content-Encoding"] = encoding
 
         return response
-    else:
-        raise Http404()
+    raise Http404()
 
 
 @login_required
@@ -125,3 +127,36 @@ def robots(request):
         {'robots': robots},
         content_type='text/plain'
     )
+
+@login_required
+def import_pages_from_csv_file(request):
+    """
+    Overwrite of the `import_pages` view from wagtailimportexport.  By default, the `import_pages` view
+    expects a json file to be uploaded.  This view converts the uploaded csv into the json format that
+    the importer expects.
+    """
+
+    if request.method == 'POST':
+        form = ImportPagesFromCSVFileForm(request.POST, request.FILES)
+        if form.is_valid():
+            import_data = convert_csv_to_json(form.cleaned_data['file'].read().decode('utf-8').splitlines(), form.cleaned_data['page_type'])
+            parent_page = form.cleaned_data['parent_page']
+            try:
+                page_count = import_pages(import_data, parent_page)
+            except LookupError as e:
+                messages.error(request, _(
+                    "Import failed: %(reason)s") % {'reason': e}
+                )
+            else:
+                messages.success(request, ungettext(
+                    "%(count)s page imported.",
+                    "%(count)s pages imported.",
+                    page_count) % {'count': page_count}
+                )
+            return redirect('wagtailadmin_explore', parent_page.pk)
+    else:
+        form = ImportPagesFromCSVFileForm()
+
+    return render(request, 'wagtailimportexport/import_from_csv.html', {
+        'form': form,
+    })

+ 2 - 2
coderedcms/wagtail_hooks.py

@@ -1,3 +1,5 @@
+import mimetypes
+
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.staticfiles.templatetags.staticfiles import static
 from django.http.response import HttpResponse
@@ -9,8 +11,6 @@ from wagtail.core.models import UserPagePermissionsProxy, get_page_models
 from coderedcms import utils
 from coderedcms.models import CoderedFormPage
 
-import mimetypes
-
 @hooks.register('insert_global_admin_css')
 def global_admin_css():
     return format_html('<link rel="stylesheet" type="text/css" href="{}">', static('css/codered-admin.css'))

+ 3 - 1
setup.py

@@ -9,7 +9,7 @@ os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir)))
 
 setup(
     name='coderedcms',
-    version='0.8.0',
+    version='0.9.0',
     packages=find_packages(),
     include_package_data=True,
     license='BSD License',
@@ -39,6 +39,8 @@ setup(
         'pygments>=2.2.0,<3.0',
         'wagtail==2.2.*',
         'wagtailfontawesome>=1.1.3,<2.0',
+        'geocoder>=1.38.1,<2.0',
+        'wagtail-import-export>=0.1,<0.2'
     ],
     entry_points="""
             [console_scripts]