Browse Source

New event pages, tags, and color field

Cory Sutyak 6 years ago
parent
commit
25e9a68792

+ 12 - 0
coderedcms/fields.py

@@ -0,0 +1,12 @@
+from django.db import models
+
+from coderedcms.widgets import ColorPickerWidget
+
+class ColorField(models.CharField):    
+    def __init__(self, *args, **kwargs):
+        kwargs['max_length'] = 255
+        super(ColorField, self).__init__(*args, **kwargs)
+
+    def formfield(self, **kwargs):
+        kwargs['widget'] = ColorPickerWidget
+        return super(ColorField, self).formfield(**kwargs)

+ 40 - 0
coderedcms/migrations/0005_auto_20181211_1536.py

@@ -0,0 +1,40 @@
+# Generated by Django 2.0.9 on 2018-12-11 20:36
+
+import coderedcms.blocks.base_blocks
+from django.db import migrations, models
+import django.db.models.deletion
+import modelcluster.contrib.taggit
+import modelcluster.fields
+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 = [
+        ('taggit', '0002_auto_20150616_2121'),
+        ('coderedcms', '0004_auto_20181119_1507'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='CoderedTag',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('content_object', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='tagged_items', to='coderedcms.CoderedPage')),
+                ('tag', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='coderedcms_coderedtag_items', to='taggit.Tag')),
+            ],
+            options={
+                'verbose_name': 'CodeRed Tag',
+            },
+        ),
+        migrations.AddField(
+            model_name='coderedpage',
+            name='tags',
+            field=modelcluster.contrib.taggit.ClusterTaggableManager(blank=True, help_text='Used to categorize your pages.', through='coderedcms.CoderedTag', to='taggit.Tag', verbose_name='Tags'),
+        ),
+    ]

+ 212 - 10
coderedcms/models/page_models.py

@@ -21,9 +21,12 @@ 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 eventtools.models import BaseEvent, BaseOccurrence
+from icalendar import Event as ICalEvent
+from modelcluster.fields import ParentalKey
+from modelcluster.tags import ClusterTaggableManager
+from taggit.models import TaggedItemBase
 from wagtail.admin.edit_handlers import (
     HelpPanel,
     FieldPanel,
@@ -35,7 +38,7 @@ from wagtail.admin.edit_handlers import (
     StreamFieldPanel,
     TabbedInterface)
 from wagtail.core.fields import StreamField
-from wagtail.core.models import PageBase, Page, Site
+from wagtail.core.models import Orderable, PageBase, Page, Site
 from wagtail.core.utils import resolve_model_string
 from wagtail.contrib.forms.edit_handlers import FormSubmissionsPanel
 from wagtail.contrib.forms.forms import WagtailAdminFormPageForm
@@ -50,18 +53,17 @@ from coderedcms.blocks import (
     ContentWallBlock,
     OpenHoursBlock,
     StructuredDataActionBlock)
+from coderedcms.fields import ColorField
 from coderedcms.forms import CoderedFormBuilder, CoderedSubmissionsListView
 from coderedcms.models.wagtailsettings_models import GeneralSettings, LayoutSettings, SeoSettings, GoogleApiSettings
 from coderedcms.settings import cr_settings
 
-
 CODERED_PAGE_MODELS = []
 
 
 def get_page_models():
     return CODERED_PAGE_MODELS
 
-
 class CoderedPageMeta(PageBase):
     def __init__(cls, name, bases, dct):
         super().__init__(name, bases, dct)
@@ -82,6 +84,10 @@ class CoderedPageMeta(PageBase):
         if not cls._meta.abstract:
             CODERED_PAGE_MODELS.append(cls)
 
+class CoderedTag(TaggedItemBase):
+    class Meta:
+        verbose_name = _('CodeRed Tag')
+    content_object = ParentalKey('coderedcms.CoderedPage', related_name='tagged_items')
 
 class CoderedPage(Page, metaclass=CoderedPageMeta):
     """
@@ -135,7 +141,6 @@ class CoderedPage(Page, metaclass=CoderedPageMeta):
         ('title', _('Title, alphabetical')),
         ('-title', _('Title, reverse alphabetical')),
     )
-
     index_show_subpages = models.BooleanField(
         default=index_show_subpages_default,
         verbose_name=_('Show list of child pages')
@@ -150,7 +155,12 @@ class CoderedPage(Page, metaclass=CoderedPageMeta):
         default=10,
         verbose_name=_('Number per page'),
     )
-
+    tags = ClusterTaggableManager(
+        through=CoderedTag,
+        verbose_name='Tags',
+        blank=True,
+        help_text=_('Used to categorize your pages.')
+    )
 
     ###############
     # Layout fields
@@ -325,6 +335,7 @@ class CoderedPage(Page, metaclass=CoderedPageMeta):
         Page.content_panels +
         [
             ImageChooserPanel('cover_image'),
+            FieldPanel('tags'),
         ]
     )
 
@@ -386,7 +397,7 @@ class CoderedPage(Page, metaclass=CoderedPageMeta):
     ]
 
     settings_panels = (
-        Page.settings_panels + 
+        Page.settings_panels +
         [
             StreamFieldPanel('content_walls'),
         ]
@@ -477,7 +488,7 @@ class CoderedPage(Page, metaclass=CoderedPageMeta):
                     current_content_walls.append(wall.value)
         else:
             current_content_walls = self.content_walls
-            
+
         try:
             return list(current_content_walls) + self.get_parent().specific.get_content_walls()
         except AttributeError:
@@ -546,8 +557,8 @@ class CoderedWebPage(CoderedPage):
         # strip tags
         body = strip_tags(body)
         # truncate and add ellipses
-        return body[:200] + "..."
 
+        return body[:200] + "..." if len(body) > 200 else body
 
     @property
     def page_ptr(self):
@@ -673,6 +684,10 @@ class CoderedArticleIndexPage(CoderedWebPage):
 
     index_show_subpages_default = True
 
+    index_order_by_default = '-date_display'
+    index_order_by_choices = (('-date_display', 'Display publish date, newest first'),) + \
+        CoderedWebPage.index_order_by_choices
+
     show_images = models.BooleanField(
         default=True,
         verbose_name=_('Show images'),
@@ -705,6 +720,193 @@ class CoderedArticleIndexPage(CoderedWebPage):
     )
 
 
+class CoderedEventPage(CoderedWebPage, BaseEvent):
+    class Meta:
+        verbose_name = _('CodeRed Event')
+        abstract = True
+
+    calendar_color = ColorField(
+        blank=True,
+        help_text=_('The color that the event will use when displayed on a calendar.'),
+    )
+    address = models.TextField(
+        blank=True,
+        verbose_name=_("Address")
+    )
+    content_panels = (
+        CoderedWebPage.content_panels +
+        [
+            MultiFieldPanel(
+                [
+                    FieldPanel('calendar_color'),
+                ],
+                heading=_('Event information')
+            ),
+            FieldPanel('address'),
+            InlinePanel(
+                'occurrences',
+                heading="Occurrences",
+            ),
+        ]
+    )
+
+    @property
+    def upcoming_occurrences(self):
+        """
+        Returns the next x occurrences for this event.
+
+        By default, it returns 10.
+        """
+        return self.query_occurrences(num_of_instances_to_return=10)
+
+    def query_occurrences(self, num_of_instances_to_return=None, **kwargs):
+        """
+        Returns a list of all upcoming event instances for the specified query.
+        For more information on what you can query with, visit
+        https://github.com/gregplaysguitar/django-eventtools
+        """
+        event_instances = []
+        occurrence_kwargs = {
+            'from_date': kwargs.get('from_date', timezone.now().date())
+        }
+
+        if 'limit' in kwargs:
+            if kwargs['limit'] != None:
+                # Limit the number of event instances that will be generated per occurrence rule to 10, if not otherwise specified.
+                occurrence_kwargs['limit'] = kwargs.get('limit', 10)
+
+        # For each occurrence rule in all of the occurrence rules for this event.
+        for occurrence in self.occurrences.all():
+
+            # Add the qualifying generated event instances to the list.
+            event_instances += [instance for instance in occurrence.all_occurrences(**occurrence_kwargs)]
+
+        # Sort all the events by the date that they start
+        event_instances.sort(key=lambda d: d[0])
+
+        # Return the event instances, possibly spliced if num_instances_to_return is set.
+        return event_instances[:num_of_instances_to_return] if num_of_instances_to_return else event_instances
+
+    def convert_to_ical_format(self, dt_start=None, dt_end=None, occurrence=None):
+        ical_event = ICalEvent()
+        ical_event.add('summary', self.title)
+        if dt_start:
+            ical_event.add('dtstart', dt_start)
+
+            if dt_end:
+                ical_event.add('dtend', dt_end)
+
+        if occurrence:
+            freq = occurrence.repeat.split(":")[1] if occurrence.repeat else None
+            repeat_until = occurrence.repeat_until.strftime("%Y%m%dT000000Z") if occurrence.repeat_until else None
+
+            ical_event.add('dtstart', occurrence.start)
+
+            if occurrence.end:
+                ical_event.add('dtend', occurrence.end)
+
+            if freq:
+                ical_event.add('RRULE', freq, encode=False)
+
+            if repeat_until:
+                ical_event.add('until', repeat_until)
+
+        return ical_event
+
+    def create_single_ical(self, dt_start, dt_end=None):
+        return self.convert_to_ical_format(dt_start=dt_start, dt_end=dt_end)
+
+    def create_recurring_ical(self):
+        events = []
+        for occurrence in self.occurrences.all():
+            events.append(self.convert_to_ical_format(occurrence=occurrence))
+        return events
+
+class DefaultCalendarViewChoices():
+    MONTH = 'month'
+    AGENDA_WEEK = 'agendaWeek'
+    AGENDA_DAY = 'agendaDay'
+    LIST_MONTH = 'listMonth'
+
+    CHOICES = (
+            (MONTH, 'Monthly Calendar'),
+            (AGENDA_WEEK, 'Weekly Calendar'),
+            (AGENDA_DAY, 'Daily Calendar'),
+            (LIST_MONTH, 'Monthly List'),
+        )
+
+class CoderedEventIndexPage(CoderedWebPage):
+    """
+    Shows a list of event sub-pages.
+    """
+    class Meta:
+        verbose_name = _('CodeRed Event Index Page')
+        abstract = True
+
+    template = 'coderedcms/pages/event_index_page.html'
+
+    index_show_subpages_default = True
+
+    index_order_by_default = 'next_occurrence'
+    index_order_by_choices = (
+            ('next_occurrence', 'Display next occurrence, soonest first'),
+        ) + \
+        CoderedWebPage.index_order_by_choices
+
+    default_calendar_view = models.CharField(
+        blank=True,
+        choices=DefaultCalendarViewChoices.CHOICES,
+        max_length=255,
+        verbose_name=_('Default Calendar View'),
+        help_text=_('The default look of the calendar on this page.')
+    )
+
+    layout_panels = (
+        CoderedWebPage.layout_panels +
+        [
+            FieldPanel('default_calendar_view'),
+        ]
+    )
+
+    def get_index_children(self):
+        if self.index_query_pagemodel and self.index_order_by == 'next_occurrence':
+            querymodel = resolve_model_string(self.index_query_pagemodel, self._meta.app_label)
+            qs = querymodel.objects.child_of(self).live()
+            qs = sorted(qs.all(), key=lambda e: e.next_occurrence())
+            return qs
+
+        return super().get_index_children()
+
+    def get_calendar_events(self, start, end):
+        events = set()
+
+        for event_page in self.get_index_children():
+            events.add(event_page)
+
+        event_instances = []
+        for event in events:
+            occurrences = event.query_occurrences(limit=None, from_date=start, to_date=end)
+            for occurrence in occurrences:
+                event_data = {
+                    'title': event.title,
+                    'start': occurrence[0].strftime('%Y-%m-%dT%H:%M:%S'),
+                    'end' : occurrence[1].strftime('%Y-%m-%dT%H:%M:%S') if occurrence[1] else "",
+                    'description': "",
+                }
+                if event.url:
+                    event_data['url'] = event.url
+                if event.calendar_color:
+                    event_data['backgroundColor'] = event.calendar_color
+                event_instances.append(event_data)
+        return event_instances
+
+
+class CoderedEventOccurrence(Orderable, BaseOccurrence):
+    class Meta:
+        verbose_name = _('CodeRed Event Occurrence')
+        abstract = True
+
+
 class CoderedFormPage(CoderedWebPage):
     """
     This is basically a clone of wagtail.contrib.forms.models.AbstractForm

+ 0 - 5
coderedcms/project_template/website/models.py

@@ -1,9 +1,7 @@
 """
 Createable pages used in CodeRed CMS.
 """
-
 from modelcluster.fields import ParentalKey
-
 from coderedcms.forms import CoderedFormField
 from coderedcms.models import (
     CoderedArticlePage,
@@ -38,9 +36,6 @@ class ArticleIndexPage(CoderedArticleIndexPage):
 
     # Override to specify custom index ordering choice/default.
     index_query_pagemodel = 'website.ArticlePage'
-    index_order_by_default = '-date_display'
-    index_order_by_choices = (('-date_display', 'Display publish date, newest first'),) + \
-        CoderedArticleIndexPage.index_order_by_choices
 
     # Only allow ArticlePages beneath this page.
     subpage_types = ['website.ArticlePage']

+ 4 - 0
coderedcms/static/css/codered-editor.css

@@ -55,6 +55,7 @@ label,
 .input input[type='time'],
 .input input[type='datetime'],
 .input input[type='datetime-local'],
+.input input[type='color'],
 .input select,
 .input textarea {
     min-width:300px;
@@ -64,6 +65,9 @@ label,
 .full .input input {
     width:100%;
 }
+.input input[type='color']{
+    height: 40px;
+}
 
 
 /* Override and enhance the streamfield editor */

+ 85 - 0
coderedcms/static/js/codered-front.js

@@ -22,6 +22,19 @@ libs = {
         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" />'
     },
+    jquery_ui: {
+        url: "https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.js",
+        integrity: "sha256-T0Vest3yCU7pafRw9r+settMBX6JkKN06dqBnpQ8d30=",
+    },
+    jquery_qtip: {
+        url: "https://cdnjs.cloudflare.com/ajax/libs/qtip2/3.0.3/basic/jquery.qtip.min.js",
+        integrity: "sha256-219NoyU6iEtgMGleoW1ttROUEs/sux5DplKJJQefDwE=",
+    },
+    fullcalendar: {
+        url: "https://cdnjs.cloudflare.com/ajax/libs/fullcalendar/3.9.0/fullcalendar.js",
+        integrity: "sha256-uKe4jCg18Q60qLNG8dIei2y3ZVhcHADuEQFlpQ/hBRY=",
+        head: '<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/fullcalendar/3.9.0/fullcalendar.css" integrity="sha256-IGidWbiBOL+/w1glLnZWR5dCXpBrtQbY3XOUt2TTQOM=" crossorigin="anonymous" />'
+    },
     coderedmaps: {
         url: "/static/js/codered-maps.js",
         integrity: "",
@@ -30,6 +43,8 @@ libs = {
 
 function load_script(lib, success) {
     // lib is an entry in `libs` above.
+    // It is best to put functionality related to the script you are loading into the success callback of the load_script function.
+    // Otherwise, it might not work as intended.
     if(lib.head) {
         $('head').append(lib.head);
     }
@@ -47,6 +62,38 @@ function load_script(lib, success) {
 
 $(document).ready(function()
 {
+
+    /*** AJAX Setup CSRF Setup ***/
+    function getCookie(name) {
+        var cookieValue = null;
+        if (document.cookie && document.cookie !== '') {
+            var cookies = document.cookie.split(';');
+            for (var i = 0; i < cookies.length; i++) {
+                var cookie = jQuery.trim(cookies[i]);
+                // Does this cookie string begin with the name we want?
+                if (cookie.substring(0, name.length + 1) === (name + '=')) {
+                    cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
+                    break;
+                }
+            }
+        }
+        return cookieValue;
+    }
+    var csrftoken = getCookie('csrftoken');
+
+    function csrfSafeMethod(method) {
+        // these HTTP methods do not require CSRF protection
+        return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method));
+    }
+    $.ajaxSetup({
+        beforeSend: function(xhr, settings) {
+            if (!csrfSafeMethod(settings.type) && !this.crossDomain) {
+                xhr.setRequestHeader("X-CSRFToken", csrftoken);
+            }
+        }
+    });
+
+
     /*** Forms ***/
     if ( $('form').length > 0) {
         load_script(libs.modernizr, function() {
@@ -94,6 +141,44 @@ $(document).ready(function()
             }
         });
     }
+
+    /*** Calendar **/
+    if ( $("[data-block='calendar']").length > 0){
+        load_script(libs.jquery_ui, function(){
+            load_script(libs.jquery_qtip, function(){
+                load_script(libs.moment, function(){
+                    load_script(libs.fullcalendar, function(){
+                        var pageId = "";
+                        var defaultDate = "";
+                        var defaultView = "";
+                        $('[data-block="calendar"]').each(function(index, obj){
+                            pageId = $(obj).data('page-id');
+                            defaultDate = $(obj).data('default-date');
+                            defaultView = $(obj).data('default-view');
+                            $(obj).fullCalendar({
+                                header: {
+                                    left: 'prev,next,today',
+                                    center: 'title',
+                                    right: 'month,agendaWeek,agendaDay,listMonth'
+                                },
+                                defaultDate: defaultDate,
+                                defaultView: defaultView,
+                                fixedWeekCount: false,
+                                events: {
+                                    url: '/ajax/calendar/events/',
+                                    type: 'POST',
+                                    data: {
+                                        'page_id': pageId
+                                    }
+                                }
+                            });
+                        });
+                    });
+                });
+            });
+        });
+    }
+
     if ($('#cr-map').length > 0) {
         load_script(libs.coderedmaps, function() {
             $.ajax({

+ 5 - 0
coderedcms/templates/coderedcms/includes/ical/calendar_ical_button.html

@@ -0,0 +1,5 @@
+<form data-attribute="calendar-ical-form" class="calendar-ical-form" action="{% url 'event_generate_ical_for_calendar' %}" method="POST">
+        <input name="page_id" type="number" hidden value="{{ page.id }}" />
+        {% csrf_token %}
+    <button class="btn btn-primary" type="submit">{% block button_text %}Download Calendar{% endblock %}</button>
+</form>

+ 5 - 0
coderedcms/templates/coderedcms/includes/ical/recurring_ical_button.html

@@ -0,0 +1,5 @@
+<form data-attribute="recurring-ical-form-{{event.pk}}" class="recurring-ical-form" action="{% url 'event_generate_recurring_ical' %}" method="POST">
+    {% csrf_token %}
+    <input name="event_pk" type="number" hidden value="{{event.pk}}" />
+    <button class="btn btn-primary" type="submit">{% block button_text %}Add All Events To Calendar{% endblock %}</button>
+</form>

+ 7 - 0
coderedcms/templates/coderedcms/includes/ical/single_ical_button.html

@@ -0,0 +1,7 @@
+<form data-attribute="single-ical-form-{{event.pk}}-{{datetime_start}}" class="single-ical-form" action="{% url 'event_generate_single_ical' %}" method="POST">
+    {% csrf_token %}
+    <input name="event_pk" type="number" hidden value="{{event.pk}}" />
+    <input name="datetime_start" type="text" hidden value="{{datetime_start}}" />
+    <input name="datetime_end" type="text" hidden value="{{datetime_end}}" />
+    <button class="btn btn-primary" type="submit">{% block button_text %}Add to Calendar{% endblock %}</button>
+</form>

+ 7 - 0
coderedcms/templates/coderedcms/includes/iframe_gmap.html

@@ -0,0 +1,7 @@
+{% if address %}
+<div class="row mb-2 mt-2">
+    <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={{ address }}&output=embed" allowfullscreen></iframe>
+    </div>
+</div>
+{% endif %}

+ 49 - 0
coderedcms/templates/coderedcms/includes/struct_data_event.json

@@ -0,0 +1,49 @@
+{% load wagtailimages_tags coderedcms_tags %}
+
+{
+  "@context": "http://schema.org",
+ "mainEntityOfPage": {
+    "@type": "WebPage",
+    "@id": "{{page.get_full_url}}"
+  },
+  "description": "{{page.get_description}}",
+
+  {# Get different aspect ratios. Use huge numbers because wagtail will not upscale, #}
+  {# but will max out at the image's original resultion using the specified aspect ratio. #}
+  {# Google wants them high resolution. #}
+  {% if page.og_image %}
+    {% image page.struct_org_image fill-10000x10000 as img_11 %}
+    {% image page.struct_org_image fill-40000x30000 as img_21 %}
+    {% image page.struct_org_image fill-16000x9000 as img_169 %}
+    "image": [
+        "{{self.get_site.root_url}}{{img_11.url}}",
+        "{{self.get_site.root_url}}{{img_21.url}}",
+        "{{self.get_site.root_url}}{{img_169.url}}"
+    ],
+  {% elif page.cover_image %}
+    {% image page.cover_image fill-10000x10000 as img_11 %}
+    {% image page.cover_image fill-40000x30000 as img_21 %}
+    {% image page.cover_image fill-16000x9000 as img_169 %}
+    "image": [
+        "{{self.get_site.root_url}}{{img_11.url}}",
+        "{{self.get_site.root_url}}{{img_21.url}}",
+        "{{self.get_site.root_url}}{{img_169.url}}"
+    ],
+  {% endif %}
+
+  "location":{
+    "@type": "Place",
+    "name": "{{self.title}}",
+    "address":{
+      "@type": "PostalAddress",
+      "streetAddress": "{{self.address}}",
+    }
+  },
+  "name": "{{self.title}}",
+  "startDate": "{{self.next_occurrence.0|structured_data_datetime}}",
+  {% if self.next_occurrence.1 != '' %}
+  "endDate": "{{self.next_occurrence.1|structured_data_datetime}}",
+  {% endif %}
+
+  "@type": "Event"
+}

+ 48 - 0
coderedcms/templates/coderedcms/pages/event_index_page.html

@@ -0,0 +1,48 @@
+{% extends "coderedcms/pages/web_page.html" %}
+
+{% load wagtailcore_tags wagtailimages_tags coderedcms_tags %}
+
+{% block content_post_body %}
+    <div class="container">
+        <div class="row">
+            <div class="col pt-5 pb-5">
+                <div data-block="calendar" data-default-date='{% now "Y-m-d" %}' data-default-view='{{self.default_calendar_view}}' data-page-id="{{ page.id }}">
+                    <noscript>Javascript is required to view the Calendar.</noscript>
+                </div>
+            </div>
+        </div>
+        <div class="row">
+            <div class="col pb-5">
+                {% include 'coderedcms/includes/ical/calendar_ical_button.html' %}
+            </div>
+        </div>
+    </div>
+{% endblock %}
+
+{% block index_content %}
+    <div class="container">
+        {% for event in index_paginated %}
+            <div class="row">
+                {% block event_cover_image %}
+                    {% if event.cover_image %}
+                    <div class="col-md">
+                        {% image event.cover_image fill-1000x500 as cover_image %}
+                        <a href="{{ event.url }}" title="{{ event.title }}"><img src="{{ cover_image.url }}" class="w-100" alt="{{ event.title }}" /></a>
+                    </div>
+                    {% endif %}
+                {% endblock %}
+                {% block event_body_preview %}
+                    <div class="col-md">
+                        <h3><a href="{{ event.url }}">{{ event.title }}</a></h3>
+                        <p>{{ event.next_occurrence.0 }}</p>
+                        <p>{{ event.body_preview }}</p>
+                    </div>
+                {% endblock %}
+            </div>
+            {% if not forloop.last %}
+                <hr>
+            {% endif %}
+        {% endfor %}
+        {% include "coderedcms/includes/pagination.html" with items=index_paginated %}
+    </div>
+{% endblock %}

+ 76 - 0
coderedcms/templates/coderedcms/pages/event_page.html

@@ -0,0 +1,76 @@
+{% extends "coderedcms/pages/web_page.html" %}
+
+{% load wagtailadmin_tags wagtailcore_tags wagtailimages_tags coderedcms_tags %}
+
+{% block description %}{{self.get_description}}{% endblock %}
+
+
+{% if settings.coderedcms.SeoSettings.og_meta %}
+    {% block og_description %}{{self.get_description}}{% endblock %}
+    {% block og_image %}
+        {% if self.cover_image %}
+            {% image self.cover_image fill-2000x1000 as cover_image %}
+            {{self.get_site.root_url}}{{cover_image.url}}
+        {% else %}
+            {{block.super}}
+        {% endif %}
+    {% endblock %}
+{% endif %}
+
+{% if settings.coderedcms.SeoSettings.twitter_meta %}
+    {% block twitter_card %}{% if self.cover_image %}summary_large_image{% else %}{{block.super}}{% endif %}{% endblock %}
+{% endif %}
+
+{% block content_post_body %}
+    {% block map %}
+        <div class="container">
+            {% include 'coderedcms/includes/iframe_gmap.html' with address=page.address %}
+        </div>
+    {% endblock %}
+
+    {% block upcoming_dates %}
+        {% if self.upcoming_occurrences %}
+            <div class="container">
+                <div class="row">
+                    <div class="col">
+                        <h3 style="display:inline-block;">Upcoming Dates</h3>
+                        <div class="table-responsive">
+                            <table class="table table-striped">
+                                <tr>
+                                    <th>Starting Time</th>
+                                    <th>Ending Time</th>
+                                    <th>Actions</th>
+                                </tr>
+                                {% for date in self.upcoming_occurrences %}
+                                <tr>
+                                    <td>{{date.0}}</td>
+                                    <td>{% if date.0 != date.1 %}{{date.1}}{% endif %}</td>
+                                    <td>{% include "coderedcms/includes/ical/single_ical_button.html" with event=self datetime_start=date.0|date:"c" datetime_end=date.1|date:"c" %}</td>
+                                </tr>
+                                {% endfor %}
+                            </table>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        {% endif %}
+    {% endblock %}
+
+    {% block ical %}
+        <div class="container">
+            <div class="row">
+                <div class="col">
+                    {% include "coderedcms/includes/ical/recurring_ical_button.html" with event=self %}
+                </div>
+            </div>
+        </div>
+    {% endblock %}
+{% endblock %}
+
+{% if settings.coderedcms.SeoSettings.struct_meta %}
+    {% block struct_seo_extra %}
+    <script type="application/ld+json">
+        {% include "coderedcms/includes/struct_data_event.json" with page=self %}
+    </script>
+    {% endblock %}
+{% endif %}

+ 1 - 7
coderedcms/templates/coderedcms/pages/location_page.html

@@ -30,13 +30,7 @@
         {% 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>
+        {% include 'coderedcms/includes/iframe_gmap.html' with address=page.address %}
     {% endblock %}
 </div>
 {% endblock %}

+ 11 - 0
coderedcms/templatetags/coderedcms_tags.py

@@ -1,8 +1,10 @@
 import string
 import random
+from datetime import datetime
 from django import template
 from django.conf import settings
 from django.forms import ClearableFileInput
+from django.utils import timezone
 from django.utils.html import mark_safe
 from django.utils.formats import localize
 from wagtail.core.models import Collection
@@ -113,3 +115,12 @@ def query_update(querydict, key=None, value=None):
             except:
                 pass
     return get
+
+@register.filter
+def structured_data_datetime(dt):
+    """
+    Formats datetime object to structured data compatible datetime string.
+    """
+    if dt.time():
+        return datetime.strftime(dt, "%Y-%m-%dT%H:%M")
+    return datetime.strftime(dt, "%Y-%m-%d")

+ 17 - 1
coderedcms/urls.py

@@ -6,7 +6,15 @@ from wagtail.core import views as wagtail_views
 from wagtailcache.cache import cache_page
 
 from coderedcms.settings import cr_settings
-from coderedcms.views import robots, serve_protected_file
+from coderedcms.views import (
+    event_generate_ical_for_calendar,
+    event_generate_recurring_ical_for_event,
+    event_generate_single_ical_for_event,
+    event_get_calendar_events,
+    robots, 
+    serve_protected_file
+)
+
 
 urlpatterns = [
     # CodeRed custom URLs
@@ -22,6 +30,14 @@ urlpatterns = [
     ),
     path('_util/login/', LoginView.as_view(template_name=WAGTAIL_FRONTEND_LOGIN_TEMPLATE), name='wagtailcore_login'),
 
+    #ICAL URLS
+    path('ical/generate/single/', event_generate_single_ical_for_event, name='event_generate_single_ical'),
+    path('ical/generate/recurring/', event_generate_recurring_ical_for_event, name='event_generate_recurring_ical'),
+    path('ical/generate/calendar/', event_generate_ical_for_calendar, name='event_generate_ical_for_calendar'),
+
+    #Calendar URLS
+    path('ajax/calendar/events/', event_get_calendar_events, name='event_get_calendar_events'),
+
     # Wrap the serve function with coderedcms cache
     re_path(serve_pattern, cache_page(wagtail_views.serve), name='wagtail_serve'),
 

+ 12 - 1
coderedcms/utils.py

@@ -1,10 +1,10 @@
+
 from django.core.validators import URLValidator
 from django.core.exceptions import ValidationError
 from django.utils.html import mark_safe
 
 from coderedcms.settings import cr_settings
 
-
 def get_protected_media_link(request, path, render_link=False):
     if render_link:
         return mark_safe("<a href='{0}{1}'>{0}{1}</a>".format(request.build_absolute_uri('/')[:-1], path))
@@ -26,3 +26,14 @@ def attempt_protected_media_value_conversion(request, value):
     except AttributeError:
         pass
     return new_value
+
+def fix_ical_datetime_format(dt_str):
+    """
+    ICAL generation gives timezones in the format of 2018-06-30T14:00:00-04:00.
+    The Timezone offset -04:00 has a character not recognized by the timezone offset
+    code (%z).  The being the colon in -04:00.  We need it to instead be -0400
+    """
+    if dt_str and ":" == dt_str[-3:-2]:
+        dt_str = dt_str[:-3] + dt_str[-2:]
+        return dt_str
+    return dt_str

+ 79 - 4
coderedcms/views.py

@@ -3,22 +3,27 @@ import os
 
 from itertools import chain
 
+from datetime import datetime
+from django.http import Http404, HttpResponse, JsonResponse
 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 redirect, render
 from django.utils.translation import ungettext, ugettext_lazy as _
+from icalendar import Calendar
 
 from wagtail.admin import messages
+from wagtail.core.models import Page
 from wagtail.search.backends import db, get_search_backend
 from wagtail.search.models import Query
 
 from coderedcms.forms import SearchForm
+from coderedcms.models import CoderedPage, CoderedEventPage, get_page_models, GeneralSettings
 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.
@@ -94,7 +99,6 @@ def search(request):
         'results_paginated': results_paginated
     })
 
-
 @login_required
 def serve_protected_file(request, path):
     """
@@ -111,7 +115,6 @@ def serve_protected_file(request, path):
         return response
     raise Http404()
 
-
 def robots(request):
     robots = GeneralSettings.for_site(request.site).robots
     return render(
@@ -121,6 +124,78 @@ def robots(request):
         content_type='text/plain'
     )
 
+def event_generate_single_ical_for_event(request):
+    if request.method == "POST":
+        event_pk = request.POST['event_pk']
+        event_page_models = CoderedEventPage.__subclasses__()
+        dt_start_str = utils.fix_ical_datetime_format(request.POST['datetime_start'])
+        dt_end_str = utils.fix_ical_datetime_format(request.POST['datetime_end'])
+
+        dt_start = datetime.strptime(dt_start_str, "%Y-%m-%dT%H:%M:%S%z") if dt_start_str else None
+        dt_end = datetime.strptime(dt_end_str, "%Y-%m-%dT%H:%M:%S%z") if dt_end_str else None
+        for event_page_model in event_page_models:
+            try:
+                event = event_page_model.objects.get(pk=event_pk)
+                break
+            except event_page_model.DoesNotExist:
+                pass
+        ical = Calendar()
+        ical.add_component(event.create_single_ical(dt_start=dt_start, dt_end=dt_end))
+        response = HttpResponse(ical.to_ical(), content_type="text/calendar")
+        response['Filename'] = "{0}.ics".format(event.slug)
+        response['Content-Disposition'] = 'attachment; filename={0}.ics'.format(event.slug)
+        return response
+    raise Http404()
+
+def event_generate_recurring_ical_for_event(request):
+    if request.method == "POST":
+        event_pk = request.POST['event_pk']
+        event_page_models = CoderedEventPage.__subclasses__()
+        for event_page_model in event_page_models:
+            try:
+                event = event_page_model.objects.get(pk=event_pk)
+                break
+            except event_page_modal.DoesNotExist:
+                pass
+        ical = Calendar()
+        for e in event.create_recurring_ical():
+            ical.add_component(e)
+        response = HttpResponse(ical.to_ical(), content_type="text/calendar")
+        response['Filename'] = "{0}.ics".format(event.slug)
+        response['Content-Disposition'] = 'attachment; filename={0}.ics'.format(event.slug)
+        return response
+    raise Http404()
+
+def event_generate_ical_for_calendar(request):
+    if request.method == "POST":
+        try:
+            page = CoderedPage.objects.get(id=request.POST.get('page_id')).specific
+            print(page)
+        except ValueError:
+            raise Http404
+
+        ical = Calendar()
+        for event_page in page.get_index_children():
+            for e in event_page.specific.create_recurring_ical():
+                ical.add_component(e)
+        response = HttpResponse(ical.to_ical(), content_type="text/calendar")
+        response['Filename'] = "calendar.ics"
+        response['Content-Disposition'] = 'attachment; filename=calendar.ics'
+        return response
+    raise Http404()
+
+def event_get_calendar_events(request):
+    if request.is_ajax():
+        try:
+            page = CoderedPage.objects.get(id=request.POST.get('page_id')).specific
+        except ValueError:
+            raise Http404
+        start_str = request.POST.get('start')
+        start = datetime.strptime(start_str[:10], "%Y-%m-%d") if start_str else None
+        end_str = request.POST.get('end')
+        end = datetime.strptime(end_str[:10], "%Y-%m-%d") if end_str else None
+        return JsonResponse(page.get_calendar_events(start=start, end=end), safe=False)
+    raise Http404()
 
 @login_required
 def import_pages_from_csv_file(request):

+ 4 - 0
coderedcms/widgets.py

@@ -0,0 +1,4 @@
+from django import forms
+
+class ColorPickerWidget(forms.TextInput):
+    input_type = 'color'

+ 52 - 0
docs/features/events.rst

@@ -0,0 +1,52 @@
+Events
+=============
+
+Event pages allow users to create a calendar or list of events.  These lists/calendars allow
+users to download ical invitations to their own calendars.
+
+There are two abstract pages when dealing with events.  The first ``CoderedEventPage`` holds 
+the information regarding the event.  Dates, location, etc all will fall under this page.  The
+``CoderedEventIndexPage`` will aggregate its children ``CoderedEventPage`` and display them in a calendar.  
+
+The event functionality is built-in to Codered CMS but it is not enabled by default.  To implement,
+add the following to your ``website/models.py``::
+
+    from modelcluster.fields import ParentalKey
+    from coderedcms.models import (
+        CoderedEventPage,
+        CoderedEventIndexPage,
+        CoderedEventOccurrence
+    )
+
+    class EventPage(CoderedEventPage):
+        class Meta:
+            verbose_name = 'Event Page'
+
+        parent_page_types = ['website.EventIndexPage']
+        subpage_types = []
+        template = 'coderedcms/pages/event_page.html'
+
+
+    class EventIndexPage(CoderedEventIndexPage):
+        """
+        Shows a list of event sub-pages.
+        """
+        class Meta:
+            verbose_name = 'Events Landing Page'
+
+        index_query_pagemodel = 'website.EventPage'
+
+        # Only allow EventPages beneath this page.
+        subpage_types = ['website.EventPage']
+
+        template = 'coderedcms/pages/event_index_page.html'
+
+
+    class EventOccurrence(CoderedEventOccurrence):
+        event = ParentalKey(EventPage, related_name='occurrences')
+
+
+Next run ``python manage.py makemigrations`` and ``python manage.py migrate`` to create the new pages
+in your project.
+
+Now when going to the wagtail admin, you can create an EventIndexPage, and child EventPages.

+ 2 - 1
docs/features/index.rst

@@ -4,5 +4,6 @@ Features
 .. toctree::
     :maxdepth: 1
 
+    events
     import_export
-    store_locator
+    store_locator

+ 2 - 0
setup.py

@@ -40,9 +40,11 @@ setup(
         'Topic :: Internet :: WWW/HTTP :: Site Management',
     ],
     install_requires=[
+        'django-eventtools==0.9.*',
         'django-bootstrap4',
         'django>=1.11,<2.2',
         'geocoder>=1.38.1,<2.0',
+        'icalendar==4.0.*',
         'pygments>=2.2.0,<3.0',
         'wagtail==2.3.*',
         'wagtailfontawesome>=1.1.3,<2.0',