Add sharing options #184

This commit is contained in:
Daniel Grams 2021-06-01 16:50:51 +02:00
parent 739ca190e2
commit 4419ea1b93
16 changed files with 376 additions and 32 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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){

View File

@ -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) %}
<div class="w-normal mx-auto">
{% if event.photo_id %}
@ -563,7 +550,20 @@
<div class="mt-4" style="white-space:pre-wrap;">{{ event.description|urlize(nofollow=True, target='_blank', rel="nofollow") }}</div>
{% endif %}
<div class="small mt-4">
{% if share_links or calendar_links %}
<div class="mt-4">
{% if share_links %}
<button type="button" class="btn btn-outline-secondary mr-1 mb-1" data-toggle="modal" data-target="#shareModal"><i class="fa fa-share-alt"></i> {{ _('Share') }}</button>
{{ render_share_modal(share_links) }}
{% endif %}
{% if calendar_links %}
<button type="button" class="btn btn-outline-secondary mb-1" data-toggle="modal" data-target="#calendarExportModal"><i class="fa fa-calendar"></i> {{ _('Add to calendar') }}</button>
{{ render_calendar_export_modal(calendar_links) }}
{% endif %}
</div>
{% endif %}
<div class="small mt-2">
{{ render_audit(event, show_rating) }}
</div>
</div>
@ -588,7 +588,7 @@
{% if event.attendance_mode and event.attendance_mode.value != 2 %}
<div class="mt-2">
<a href="http://www.google.com/maps?q={{ render_place(event.event_place) | quote_plus }}" class="btn btn-secondary" target="_blank" rel="noopener noreferrer">{{ _('Show directions') }}</a>
<a href="http://www.google.com/maps?q={{ render_place(event.event_place) | quote_plus }}" class="btn btn-outline-secondary" target="_blank" rel="noopener noreferrer"><i class="fa fa-directions"></i> {{ _('Show directions') }}</a>
</div>
{% endif %}
@ -1248,4 +1248,58 @@ if (URL) {
{% endif %}
</style>
{% endmacro %}
{% endmacro %}
{% macro render_share_modal(share_links) %}
<div class="modal fade" id="shareModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ _('Share event') }}</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="input-group mb-3">
<input id="copy_input" type="text" class="form-control" value="{{ share_links["url"] }}" />
<div class="input-group-append">
<button id="copy_input_button" class="btn btn-outline-secondary" type="button" data-toggle="tooltip" data-trigger="manual" data-title="{{ _('Link copied') }}">{{ _('Copy link') }}</button>
</div>
</div>
<div class="list-group">
<a class="list-group-item list-group-item-action" href="{{ share_links["facebook"] }}" target="_blank" rel="noopener noreferrer"><i class="fab fa-facebook"></i> Facebook</a>
<a class="list-group-item list-group-item-action" href="{{ share_links["twitter"] }}" target="_blank" rel="noopener noreferrer"><i class="fab fa-twitter"></i> Twitter</a>
<a class="list-group-item list-group-item-action" href="{{ share_links["email"] }}" target="_blank" rel="noopener noreferrer"><i class="fa fa-envelope"></i> {{ _('Email') }}</a>
<a class="list-group-item list-group-item-action" href="{{ share_links["whatsapp"] }}" target="_blank" rel="noopener noreferrer" data-action="share/whatsapp/share"><i class="fab fa-whatsapp"></i> Whatsapp</a>
<a class="list-group-item list-group-item-action" href="{{ share_links["telegram"] }}" target="_blank" rel="noopener noreferrer"><i class="fab fa-telegram"></i> Telegram</a>
</div>
</div>
</div>
</div>
</div>
{% endmacro %}
{% macro render_calendar_export_modal(calendar_links) %}
<div class="modal fade" id="calendarExportModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ _('Add to calendar') }}</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="list-group">
<a class="list-group-item list-group-item-action" href="{{ calendar_links["ics"] }}"><i class="fab fa-microsoft"></i> Outlook</a>
<a class="list-group-item list-group-item-action" href="{{ calendar_links["google"] }}" target="_blank" rel="noopener noreferrer"><i class="fab fa-google"></i> {{ _('Google calendar') }}</a>
<a class="list-group-item list-group-item-action" href="{{ calendar_links["ics"] }}"><i class="fab fa-apple"></i> {{ _('Apple calendar') }}</a>
<a class="list-group-item list-group-item-action" href="{{ calendar_links["ics"] }}"><i class="fab fa-yahoo"></i> {{ _('Yahoo calendar') }}</a>
<a class="list-group-item list-group-item-action" href="{{ calendar_links["ics"] }}"><i class="fa fa-calendar"></i> {{ _('Other calendar') }}</a>
</div>
</div>
</div>
</div>
</div>
{% endmacro %}

View File

@ -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 %}
</div>
<div class="list-group mt-4">
<button type="button" class="list-group-item list-group-item-action" data-toggle="modal" data-target="#shareModal"><i class="fa fa-share-alt"></i> {{ _('Share event') }}</button>
</div>
{{ render_share_modal(share_links) }}
<div class="list-group mt-4">
{% if user_rights['can_duplicate_event'] %}
<a class="list-group-item list-group-item-action" href="{{ url_for('event_create_for_admin_unit_id', id=event.admin_unit_id, template_id=event.id) }}"><i class="fa fa-copy"></i> {{ _('Duplicate event') }}</a>
@ -42,7 +48,6 @@
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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)

View File

@ -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/<int:id>/events/create", methods=("GET", "POST"))

View File

@ -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/<int:id>/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"},
)

View File

@ -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

View File

@ -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

View File

@ -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

73
tests/test_utils.py Normal file
View File

@ -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"

View File

@ -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)