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 %}
- {{ _('Show directions') }} + {{ _('Show directions') }}
{% 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) }} +
{% if user_rights['can_duplicate_event'] %} {{ _('Duplicate event') }} @@ -42,7 +48,6 @@ {% endif %}
- {% 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)