diff --git a/project/dateutils.py b/project/dateutils.py
index 9b74ee5..ff03fab 100644
--- a/project/dateutils.py
+++ b/project/dateutils.py
@@ -1,10 +1,12 @@
-from datetime import datetime
+from datetime import datetime, timedelta
+import icalendar
import pytz
from dateutil.relativedelta import relativedelta
from dateutil.rrule import rrulestr
berlin_tz = pytz.timezone("Europe/Berlin")
+gmt_tz = pytz.timezone("GMT")
def get_now():
@@ -195,3 +197,34 @@ def calculate_occurrences(start_date, date_format, rrule_str, start, batch_size)
}
return {"occurrences": occurrences, "batch": batch_data}
+
+
+def create_icalendar() -> icalendar.Calendar:
+ cal = icalendar.Calendar()
+ cal.add("prodid", "-//Oveda//oveda.de//")
+ cal.add("version", "2.0")
+ cal.add("x-wr-timezone", berlin_tz.zone)
+
+ tzc = icalendar.Timezone()
+ tzc.add("tzid", berlin_tz.zone)
+ tzc.add("x-lic-location", berlin_tz.zone)
+
+ tzs = icalendar.TimezoneStandard()
+ tzs.add("tzname", "CET")
+ tzs.add("dtstart", datetime(1970, 10, 25, 3, 0, 0))
+ tzs.add("rrule", {"freq": "yearly", "bymonth": 10, "byday": "-1su"})
+ tzs.add("TZOFFSETFROM", timedelta(hours=2))
+ tzs.add("TZOFFSETTO", timedelta(hours=1))
+
+ tzd = icalendar.TimezoneDaylight()
+ tzd.add("tzname", "CEST")
+ tzd.add("dtstart", datetime(1970, 3, 29, 2, 0, 0))
+ tzd.add("rrule", {"freq": "yearly", "bymonth": 3, "byday": "-1su"})
+ tzd.add("TZOFFSETFROM", timedelta(hours=1))
+ tzd.add("TZOFFSETTO", timedelta(hours=2))
+
+ tzc.add_component(tzs)
+ tzc.add_component(tzd)
+ cal.add_component(tzc)
+
+ return cal
diff --git a/project/jinja_filters.py b/project/jinja_filters.py
index c7f6fe6..b67cb44 100644
--- a/project/jinja_filters.py
+++ b/project/jinja_filters.py
@@ -6,6 +6,8 @@ from project.utils import (
get_event_category_name,
get_localized_enum_name,
get_localized_scope,
+ get_location_str,
+ get_place_str,
)
@@ -36,6 +38,8 @@ app.jinja_env.filters["quote_plus"] = lambda u: quote_plus(u)
app.jinja_env.filters["is_list"] = is_list
app.jinja_env.filters["any_dict_value_true"] = any_dict_value_true
app.jinja_env.filters["ensure_link_scheme"] = lambda s: ensure_link_scheme(s)
+app.jinja_env.filters["place_str"] = lambda p: get_place_str(p)
+app.jinja_env.filters["location_str"] = lambda l: get_location_str(l)
@app.context_processor
diff --git a/project/services/event.py b/project/services/event.py
index 6b97205..c8d21f2 100644
--- a/project/services/event.py
+++ b/project/services/event.py
@@ -1,5 +1,6 @@
from datetime import datetime
+import icalendar
from dateutil.relativedelta import relativedelta
from flask import url_for
from flask_babelex import format_date, format_time
@@ -8,7 +9,12 @@ from sqlalchemy.orm import contains_eager, defaultload, joinedload, lazyload
from sqlalchemy.sql import extract
from project import db
-from project.dateutils import date_add_time, dates_from_recurrence_rule, get_today
+from project.dateutils import (
+ berlin_tz,
+ date_add_time,
+ dates_from_recurrence_rule,
+ get_today,
+)
from project.models import (
AdminUnit,
Event,
@@ -22,7 +28,7 @@ from project.models import (
Image,
Location,
)
-from project.utils import get_pending_changes
+from project.utils import get_pending_changes, get_place_str
from project.views.utils import truncate
@@ -350,3 +356,31 @@ def get_meta_data(event: Event, event_date: EventDate = None) -> dict:
meta["image"] = url_for("image", id=event.photo_id, _external=True)
return meta
+
+
+def create_ical_event_for_date(event_date: EventDate) -> icalendar.Event:
+ url = url_for("event_date", id=event_date.id, _external=True)
+
+ event = icalendar.Event()
+ event.add("summary", event_date.event.name)
+ event.add("url", url)
+ event.add("description", url)
+ event.add("uid", url)
+ event.add("dtstart", event_date.start.astimezone(berlin_tz))
+
+ if event_date.end:
+ event.add("dtend", event_date.end.astimezone(berlin_tz))
+
+ if event_date.event.created_at:
+ event.add("dtstamp", event_date.event.created_at)
+
+ if event_date.event.updated_at:
+ event.add("last-modified", event_date.event.updated_at)
+
+ if (
+ event_date.event.attendance_mode
+ and event_date.event.attendance_mode != EventAttendanceMode.online
+ ):
+ event.add("location", get_place_str(event_date.event.event_place))
+
+ return event
diff --git a/project/static/site.js b/project/static/site.js
index feb30b3..66c90a2 100644
--- a/project/static/site.js
+++ b/project/static/site.js
@@ -194,6 +194,16 @@ $( function() {
return false;
});
+ $("#copy_input_button").click(function () {
+ $("#copy_input").select();
+ document.execCommand("copy");
+ $(this).tooltip('show');
+ });
+
+ $('#copy_input_button').mouseleave(function () {
+ $(this).tooltip('hide');
+ });
+
$("#geolocation_btn").click(function () {
if ("geolocation" in navigator){
navigator.geolocation.getCurrentPosition(function(position){
diff --git a/project/templates/_macros.html b/project/templates/_macros.html
index 29b0644..c73b75a 100644
--- a/project/templates/_macros.html
+++ b/project/templates/_macros.html
@@ -89,21 +89,8 @@
{{ organizer.name }}
{% endmacro %}
-{% macro render_location(location) %}
-{%- if location.street -%}
- {{ location.street }}, {{ location.postalCode }} {{ location.city }}
-{%- elif location.postalCode or location.city -%}
- {{ location.postalCode }} {{ location.city }}
-{%- endif -%}
-{% endmacro %}
-
-{% macro render_place(place) %}
-{%- if place.location -%}
- {{ place.name }}, {{render_location(place.location)}}
-{%- else -%}
- {{ place.name }}
-{%- endif -%}
-{% endmacro %}
+{% macro render_location(location) %}{{ location | location_str }}{% endmacro %}
+{% macro render_place(place) %}{{ place | place_str }}{% endmacro %}
{% macro render_events_sub_menu() %}
{% endmacro %}
@@ -511,7 +498,7 @@
{% endmacro %}
-{% macro render_event_props_seo(event, start, end, dates = None, show_rating = False, show_admin_unit = True, user_rights=None) %}
+{% macro render_event_props_seo(event, start, end, dates = None, show_rating = False, show_admin_unit = True, user_rights=None, share_links=None, calendar_links=None) %}
{% if event.photo_id %}
@@ -563,7 +550,20 @@
{{ event.description|urlize(nofollow=True, target='_blank', rel="nofollow") }}
{% endif %}
-
+ {% if share_links or calendar_links %}
+
+ {% if share_links %}
+
+ {{ render_share_modal(share_links) }}
+ {% endif %}
+ {% if calendar_links %}
+
+ {{ render_calendar_export_modal(calendar_links) }}
+ {% endif %}
+
+ {% endif %}
+
+
{{ render_audit(event, show_rating) }}
@@ -588,7 +588,7 @@
{% if event.attendance_mode and event.attendance_mode.value != 2 %}
{% endif %}
@@ -1248,4 +1248,58 @@ if (URL) {
{% endif %}
-{% endmacro %}
\ No newline at end of file
+{% endmacro %}
+
+{% macro render_share_modal(share_links) %}
+
+{% endmacro %}
+
+{% macro render_calendar_export_modal(calendar_links) %}
+
+{% endmacro %}
diff --git a/project/templates/event/actions.html b/project/templates/event/actions.html
index 4a1a622..73e99c5 100644
--- a/project/templates/event/actions.html
+++ b/project/templates/event/actions.html
@@ -1,5 +1,5 @@
{% extends "layout.html" %}
-{% from "_macros.html" import render_event_props %}
+{% from "_macros.html" import render_share_modal %}
{%- block title -%}
{{ _('Actions for event') }}
{%- endblock -%}
@@ -28,6 +28,12 @@
{% endif %}
+
+
+
+
+ {{ render_share_modal(share_links) }}
+
-
{% endblock %}
\ No newline at end of file
diff --git a/project/templates/event/read.html b/project/templates/event/read.html
index e81c0f5..925a600 100644
--- a/project/templates/event/read.html
+++ b/project/templates/event/read.html
@@ -6,6 +6,6 @@
{% block content_container_attribs %}{% endblock %}
{% block content %}
- {{ render_event_props_seo(event, event.start, event.end, dates, user_rights['can_update_event'], user_rights=user_rights) }}
+ {{ render_event_props_seo(event, event.start, event.end, dates, user_rights['can_update_event'], user_rights=user_rights, share_links=share_links) }}
{% endblock %}
\ No newline at end of file
diff --git a/project/templates/event_date/read.html b/project/templates/event_date/read.html
index 80257ce..6691388 100644
--- a/project/templates/event_date/read.html
+++ b/project/templates/event_date/read.html
@@ -7,6 +7,6 @@
{% block content_container_attribs %}{% endblock %}
{% block content %}
-{{ render_event_props_seo(event, event_date.start, event_date.end, dates, user_rights=user_rights) }}
+{{ render_event_props_seo(event, event_date.start, event_date.end, dates, user_rights=user_rights, share_links=share_links, calendar_links=calendar_links) }}
{% endblock %}
\ No newline at end of file
diff --git a/project/utils.py b/project/utils.py
index 57447f7..868867f 100644
--- a/project/utils.py
+++ b/project/utils.py
@@ -20,6 +20,29 @@ def get_localized_scope(scope: str) -> str:
return lazy_gettext(loc_key)
+def get_location_str(location) -> str:
+ if not location:
+ return ""
+
+ if location.street and not location.street.isspace():
+ return f"{location.street}, {location.postalCode} {location.city}"
+
+ if location.postalCode or location.city:
+ return f"{location.postalCode} {location.city}".strip()
+
+ return ""
+
+
+def get_place_str(place) -> str:
+ if not place:
+ return ""
+
+ if place.location:
+ return f"{place.name}, {get_location_str(place.location)}"
+
+ return place.name
+
+
def make_dir(path):
try:
original_umask = os.umask(0)
diff --git a/project/views/event.py b/project/views/event.py
index db9d1be..60615b7 100644
--- a/project/views/event.py
+++ b/project/views/event.py
@@ -43,6 +43,7 @@ from project.views.event_suggestion import send_event_suggestion_review_status_m
from project.views.utils import (
flash_errors,
flash_message,
+ get_share_links,
handleSqlError,
non_match_for_deletion,
send_mail,
@@ -54,6 +55,8 @@ def event(event_id):
event = get_event_with_details_or_404(event_id)
user_rights = get_menu_user_rights(event)
dates = get_upcoming_event_dates(event.id)
+ url = url_for("event", event_id=event_id, _external=True)
+ share_links = get_share_links(url, event.name)
structured_datas = list()
for event_date in dates:
@@ -70,6 +73,7 @@ def event(event_id):
meta=get_meta_data(event),
user_rights=user_rights,
canonical_url=url_for("event", event_id=event_id, _external=True),
+ share_links=share_links,
)
@@ -77,8 +81,15 @@ def event(event_id):
def event_actions(event_id):
event = Event.query.get_or_404(event_id)
user_rights = get_user_rights(event)
+ url = url_for("event", event_id=event_id, _external=True)
+ share_links = get_share_links(url, event.name)
- return render_template("event/actions.html", event=event, user_rights=user_rights)
+ return render_template(
+ "event/actions.html",
+ event=event,
+ user_rights=user_rights,
+ share_links=share_links,
+ )
@app.route("/admin_unit//events/create", methods=("GET", "POST"))
diff --git a/project/views/event_date.py b/project/views/event_date.py
index 2540317..18de5c2 100644
--- a/project/views/event_date.py
+++ b/project/views/event_date.py
@@ -1,18 +1,26 @@
import json
from flask import redirect, render_template, request, url_for
+from flask.wrappers import Response
from project import app
+from project.dateutils import create_icalendar
from project.forms.event_date import FindEventDateForm
from project.jsonld import DateTimeEncoder, get_sd_for_event_date
from project.services.event import (
+ create_ical_event_for_date,
get_event_date_with_details_or_404,
get_meta_data,
get_upcoming_event_dates,
)
from project.services.event_search import EventSearchParams
from project.views.event import get_event_category_choices, get_menu_user_rights
-from project.views.utils import flash_errors, track_analytics
+from project.views.utils import (
+ flash_errors,
+ get_calendar_links,
+ get_share_links,
+ track_analytics,
+)
def prepare_event_date_form(form):
@@ -53,6 +61,10 @@ def event_date(id):
get_sd_for_event_date(event_date), indent=2, cls=DateTimeEncoder
)
+ url = url_for("event_date", id=id, _external=True)
+ share_links = get_share_links(url, event_date.event.name)
+ calendar_links = get_calendar_links(event_date)
+
return render_template(
"event_date/read.html",
event_date=event_date,
@@ -61,4 +73,21 @@ def event_date(id):
canonical_url=url_for("event_date", id=id, _external=True),
user_rights=get_menu_user_rights(event_date.event),
dates=get_upcoming_event_dates(event_date.event_id),
+ share_links=share_links,
+ calendar_links=calendar_links,
+ )
+
+
+@app.route("/eventdate//ical")
+def event_date_ical(id):
+ event_date = get_event_date_with_details_or_404(id)
+ event = create_ical_event_for_date(event_date)
+
+ cal = create_icalendar()
+ cal.add_component(event)
+
+ return Response(
+ cal.to_ical(),
+ mimetype="text/calendar",
+ headers={"Content-disposition": f"attachment; filename=eventdate_{id}.ics"},
)
diff --git a/project/views/utils.py b/project/views/utils.py
index da2926d..afbb5fe 100644
--- a/project/views/utils.py
+++ b/project/views/utils.py
@@ -1,3 +1,5 @@
+from urllib.parse import quote_plus
+
from flask import Markup, flash, redirect, render_template, request, url_for
from flask_babelex import gettext
from flask_mail import Message
@@ -5,7 +7,9 @@ from psycopg2.errorcodes import UNIQUE_VIOLATION
from sqlalchemy.exc import SQLAlchemyError
from project import app, db, mail
-from project.models import Analytics
+from project.dateutils import gmt_tz
+from project.models import Analytics, EventAttendanceMode, EventDate
+from project.utils import get_place_str
def track_analytics(key, value1, value2):
@@ -112,3 +116,52 @@ def truncate(data: str, length: int) -> str:
return data
return (data[: length - 2] + "..") if len(data) > length else data
+
+
+def get_share_links(url: str, title: str) -> dict:
+ share_links = dict()
+ encoded_url = quote_plus(url)
+ encoded_title = quote_plus(title)
+
+ share_links[
+ "facebook"
+ ] = f"https://www.facebook.com/sharer/sharer.php?u={encoded_url}"
+ share_links[
+ "twitter"
+ ] = f"https://twitter.com/intent/tweet?url={encoded_url}&text={encoded_title}"
+ share_links["email"] = f"mailto:?subject={encoded_title}&body={encoded_url}"
+ share_links["whatsapp"] = f"whatsapp://send?text={encoded_url}"
+ share_links["telegram"] = f"https://t.me/share/url?url={encoded_url}"
+ share_links["url"] = url
+
+ return share_links
+
+
+def get_calendar_links(event_date: EventDate) -> dict:
+ calendar_links = dict()
+
+ url = url_for("event_date", id=event_date.id, _external=True)
+ encoded_url = quote_plus(url)
+ encoded_title = quote_plus(event_date.event.name)
+ start = event_date.start.astimezone(gmt_tz).strftime("%Y%m%dT%H%M%SZ")
+ if event_date.end:
+ end = event_date.end.astimezone(gmt_tz).strftime("%Y%m%dT%H%M%SZ")
+ else:
+ end = start
+
+ if (
+ event_date.event.attendance_mode
+ and event_date.event.attendance_mode != EventAttendanceMode.online
+ ):
+ location = get_place_str(event_date.event.event_place)
+ locationParam = f"&location={quote_plus(location)}"
+ else:
+ locationParam = ""
+
+ calendar_links[
+ "google"
+ ] = f"http://www.google.com/calendar/event?action=TEMPLATE&text={encoded_title}&dates={start}/{end}&details={encoded_url}{locationParam}"
+
+ calendar_links["ics"] = url_for("event_date_ical", id=event_date.id, _external=True)
+
+ return calendar_links
diff --git a/requirements.txt b/requirements.txt
index 1db03f7..2c33d56 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,6 +4,7 @@ apispec==4.0.0
apispec-webframeworks==0.5.2
appdirs==1.4.4
argh==0.26.2
+arrow==0.14.7
attrs==20.3.0
Authlib==0.15.3
Babel==2.9.0
@@ -46,6 +47,7 @@ Flask-SQLAlchemy==2.4.4
Flask-WTF==0.14.3
GeoAlchemy2==0.8.4
gunicorn==20.0.4
+icalendar==4.0.7
identify==1.5.10
idna==2.10
importlib-metadata==3.1.1
@@ -97,6 +99,7 @@ speaklater==1.3
SQLAlchemy==1.3.20
SQLAlchemy-Utils==0.36.8
swagger-spec-validator==2.7.3
+TatSu==4.4.0
toml==0.10.2
typed-ast==1.4.1
typing-extensions==3.7.4.3
diff --git a/tests/seeder.py b/tests/seeder.py
index 0db143e..e8e50f2 100644
--- a/tests/seeder.py
+++ b/tests/seeder.py
@@ -176,8 +176,10 @@ class Seeder(object):
category = get_event_category(category_name)
return category.id
- def create_event(self, admin_unit_id, recurrence_rule="", external_link=""):
- from project.models import Event
+ def create_event(
+ self, admin_unit_id, recurrence_rule="", external_link="", end=None
+ ):
+ from project.models import Event, EventAttendanceMode
from project.services.event import insert_event, upsert_event_category
with self._app.app_context():
@@ -187,6 +189,7 @@ class Seeder(object):
event.name = "Name"
event.description = "Beschreibung"
event.start = self.get_now_by_minute()
+ event.end = end
event.event_place_id = self.upsert_default_event_place(admin_unit_id)
event.organizer_id = self.upsert_default_event_organizer(admin_unit_id)
event.recurrence_rule = recurrence_rule
@@ -194,6 +197,7 @@ class Seeder(object):
event.ticket_link = ""
event.tags = ""
event.price_info = ""
+ event.attendance_mode = EventAttendanceMode.offline
insert_event(event)
self._db.session.commit()
event_id = event.id
diff --git a/tests/test_utils.py b/tests/test_utils.py
new file mode 100644
index 0000000..5397298
--- /dev/null
+++ b/tests/test_utils.py
@@ -0,0 +1,73 @@
+def test_get_location_str_none(client, seeder, app, utils):
+ from project.utils import get_location_str
+
+ location_str = get_location_str(None)
+ assert location_str == ""
+
+
+def test_get_location_str_empty(client, seeder, app, utils):
+ from project.models import Location
+ from project.utils import get_location_str
+
+ location = Location()
+
+ location_str = get_location_str(location)
+ assert location_str == ""
+
+
+def test_get_location_str_full(client, seeder, app, utils):
+ from project.models import Location
+ from project.utils import get_location_str
+
+ location = Location()
+ location.street = "Strasse"
+ location.postalCode = "PLZ"
+ location.city = "Ort"
+
+ location_str = get_location_str(location)
+ assert location_str == "Strasse, PLZ Ort"
+
+
+def test_get_location_str_no_street(client, seeder, app, utils):
+ from project.models import Location
+ from project.utils import get_location_str
+
+ location = Location()
+ location.postalCode = "PLZ"
+ location.city = "Ort"
+
+ location_str = get_location_str(location)
+ assert location_str == "PLZ Ort"
+
+
+def test_get_place_str_full(client, seeder, app, utils):
+ from project.models import EventPlace, Location
+ from project.utils import get_place_str
+
+ place = EventPlace()
+ place.name = "Name"
+ place.location = Location()
+ place.location.street = "Strasse"
+ place.location.postalCode = "PLZ"
+ place.location.city = "Ort"
+
+ place_str = get_place_str(place)
+ assert place_str == "Name, Strasse, PLZ Ort"
+
+
+def test_get_place_str_none(client, seeder, app, utils):
+ from project.utils import get_place_str
+
+ place_str = get_place_str(None)
+ assert place_str == ""
+
+
+def test_get_place_str_no_location(client, seeder, app, utils):
+ from project.models import EventPlace
+ from project.utils import get_place_str
+
+ place = EventPlace()
+ place.name = "Name"
+
+ place_str = get_place_str(place)
+ assert place_str == "Name"
diff --git a/tests/views/test_event_date.py b/tests/views/test_event_date.py
index 65952bf..bdfffa7 100644
--- a/tests/views/test_event_date.py
+++ b/tests/views/test_event_date.py
@@ -1,6 +1,6 @@
def test_read(client, seeder, utils):
user_id, admin_unit_id = seeder.setup_base()
- seeder.create_event(admin_unit_id)
+ seeder.create_event(admin_unit_id, end=seeder.get_now_by_minute())
url = utils.get_url("event_date", id=1)
utils.get_ok(url)
@@ -10,6 +10,14 @@ def test_read(client, seeder, utils):
utils.assert_response_redirect(response, "event_date", id=1)
+def test_ical(client, seeder, utils):
+ user_id, admin_unit_id = seeder.setup_base()
+ seeder.create_event(admin_unit_id, end=seeder.get_now_by_minute())
+
+ url = utils.get_url("event_date_ical", id=1)
+ utils.get_ok(url)
+
+
def test_list(client, seeder, utils):
user_id, admin_unit_id = seeder.setup_base()
seeder.create_event(admin_unit_id)