iCal for organization #393

This commit is contained in:
Daniel Grams 2023-03-30 23:47:31 +02:00
parent a2269a9123
commit bfd0a8c24c
17 changed files with 382 additions and 52 deletions

View File

@ -296,3 +296,28 @@ def get_admin_unit_organization_invitations_query(email):
def get_admin_unit_organization_invitations(email):
return get_admin_unit_organization_invitations_query(email).all()
def create_ical_events_for_admin_unit(
admin_unit: AdminUnit,
) -> list: # list[icalendar.Event]
from dateutil.relativedelta import relativedelta
from project.dateutils import get_today
from project.services.event import create_ical_events_for_event, get_events_query
from project.services.event_search import EventSearchParams
result = list()
params = EventSearchParams()
params.date_from = get_today() - relativedelta(months=1)
params.admin_unit_id = admin_unit.id
params.can_read_private_events = False
events = get_events_query(params).all()
for event in events:
ical_events = create_ical_events_for_event(event)
result.extend(ical_events)
return result

View File

@ -24,6 +24,7 @@ from project.models import (
EventAttendanceMode,
EventCategory,
EventDate,
EventDateDefinition,
EventList,
EventOrganizer,
EventPlace,
@ -461,45 +462,109 @@ def get_meta_data(event: Event, event_date: EventDate = None) -> dict:
return meta
def create_ical_event_for_date(event_date: EventDate) -> icalendar.Event:
url = url_for("event_date", id=event_date.id, _external=True)
def populate_ical_event_with_event(ical_event: icalendar.Event, model_event: Event):
ical_event.add("summary", model_event.name)
event = icalendar.Event()
event.add("summary", event_date.event.name)
event.add("url", url)
event.add("description", url)
event.add("uid", url)
if model_event.description:
desc_short = truncate(model_event.description, 300)
ical_event.add("description", desc_short)
start = event_date.start.astimezone(berlin_tz)
if model_event.created_at:
ical_event.add("dtstamp", model_event.created_at)
if event_date.allday:
event.add("dtstart", icalendar.vDate(start))
else:
event.add("dtstart", start)
if model_event.updated_at:
ical_event.add("last-modified", model_event.updated_at)
if event_date.end and event_date.end > event_date.start:
end = event_date.end.astimezone(berlin_tz)
if event_date.allday:
if not date_parts_are_equal(start, end):
next_day = round_to_next_day(end)
event.add("dtend", icalendar.vDate(next_day))
else:
event.add("dtend", end)
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 model_event.status and model_event.status == EventStatus.cancelled:
ical_event.add("status", "CANCELLED")
if (
event_date.event.attendance_mode
and event_date.event.attendance_mode != EventAttendanceMode.online
model_event.attendance_mode
and model_event.attendance_mode != EventAttendanceMode.online
and model_event.event_place
):
event.add("location", get_place_str(event_date.event.event_place))
place = model_event.event_place
place_str = get_place_str(place)
ical_event.add("location", place_str)
return event
location = place.location
if location and location.coordinate:
ical_event.add("geo", (location.latitude, location.longitude))
ical_event.add(
"X-APPLE-STRUCTURED-LOCATION",
f"geo:{location.latitude},{location.longitude}",
parameters={
"VALUE": "URI",
"X-ADDRESS": place_str, # must be same as "location"
"X-APPLE-RADIUS": "100",
"X-TITLE": place_str, # must be same as "location"
},
)
def populate_ical_event_with_datish(
ical_event: icalendar.Event, datish, recurrence_rule: str = None
):
# datish: EventDate|EventDateDefinition
start = datish.start.astimezone(berlin_tz)
if datish.allday:
ical_event.add("dtstart", icalendar.vDate(start))
else:
ical_event.add("dtstart", start)
if recurrence_rule:
ical_event.add(
"rrule", icalendar.vRecur.from_ical(recurrence_rule.replace("RRULE:", ""))
)
if datish.end and datish.end > datish.start:
end = datish.end.astimezone(berlin_tz)
if datish.allday:
if not date_parts_are_equal(start, end):
next_day = round_to_next_day(end)
ical_event.add("dtend", icalendar.vDate(next_day))
else:
ical_event.add("dtend", end)
def create_ical_event_for_date(event_date: EventDate) -> icalendar.Event:
ical_event = icalendar.Event()
populate_ical_event_with_event(ical_event, event_date.event)
populate_ical_event_with_datish(ical_event, event_date)
url = url_for("event_date", id=event_date.id, _external=True)
ical_event.add("url", url)
ical_event.add("uid", url)
return ical_event
def create_ical_event_for_date_definition(
date_definition: EventDateDefinition,
) -> icalendar.Event:
ical_event = icalendar.Event()
populate_ical_event_with_event(ical_event, date_definition.event)
populate_ical_event_with_datish(
ical_event, date_definition, date_definition.recurrence_rule
)
url = url_for("event", event_id=date_definition.event.id, _external=True)
ical_event.add("url", url)
ical_event.add("uid", f"{url}#{date_definition.id}")
return ical_event
def create_ical_events_for_event(event: Event) -> list: # list[icalendar.Event]
result = list()
for date_definition in event.date_definitions:
ical_event = create_ical_event_for_date_definition(date_definition)
result.append(ical_event)
return result
def update_recurring_dates():

View File

@ -38,6 +38,24 @@ const OrganizationRead = {
<template v-if="organization.location.street">{{ organization.location.street }}, </template>
{{ organization.location.postalCode }} {{ organization.location.city }}
</div>
<b-button v-b-modal.modal-ical variant="outline-secondary" class="mt-4">
<i class="fa fa-fw fa-calendar"></i>
{{ $t("comp.icalExport") }}
</b-button>
<b-modal id="modal-ical" :title="$t('comp.icalExport')" size="lg" ok-only>
<template #default="{ hide }">
<b-input-group class="mb-3">
<b-form-input :value="icalUrl" ref="icalInput"></b-form-input>
</b-input-group>
</template>
<template #modal-footer="{ ok, cancel, hide }">
<b-button variant="primary" @click.prevent="copyIcal()">{{ $t('comp.copy') }}</b-button>
<b-button variant="secondary" :href="icalUrl">{{ $t('comp.download') }}</b-button>
<b-button variant="outline-secondary" @click="hide()">{{ $t("shared.close") }}</b-button>
</template>
</b-modal>
</b-col>
</b-row>
@ -48,6 +66,26 @@ const OrganizationRead = {
</b-overlay>
</div>
`,
i18n: {
messages: {
en: {
comp: {
copy: "Copy link",
download: "Download",
icalCopied: "Link copied",
icalExport: "iCal calendar",
},
},
de: {
comp: {
copy: "Link kopieren",
download: "Runterladen",
icalCopied: "Link kopiert",
icalExport: "iCal Kalender",
},
},
},
},
data: () => ({
isLoading: false,
organization: null,
@ -56,6 +94,9 @@ const OrganizationRead = {
organizationId() {
return this.$route.params.organization_id;
},
icalUrl() {
return `${window.location.origin}/organizations/${this.organizationId}/ical`;
},
},
mounted() {
this.isLoading = false;
@ -79,5 +120,10 @@ const OrganizationRead = {
handleLoading(isLoading) {
this.isLoading = isLoading;
},
copyIcal() {
this.$refs.icalInput.select();
document.execCommand("copy");
this.$root.makeSuccessToast(this.$t("comp.icalCopied"))
}
},
};

View File

@ -1513,7 +1513,9 @@ $('#allday').on('change', function() {
<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>
{% if "google" in calendar_links %}
<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>
{% endif %}
<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>

View File

@ -9,6 +9,6 @@
{% block content_container_attribs %}{% endblock %}
{% block content %}
{{ render_event_props_seo(event, event.min_start_definition.start, event.min_start_definition.end, event.min_start_definition.allday, dates, user_rights['can_update_event'], user_rights=user_rights, share_links=share_links, current_user=current_user) }}
{{ render_event_props_seo(event, event.min_start_definition.start, event.min_start_definition.end, event.min_start_definition.allday, dates, user_rights['can_update_event'], user_rights=user_rights, share_links=share_links, calendar_links=calendar_links, current_user=current_user) }}
{% endblock %}

View File

@ -105,6 +105,7 @@
},
},
cancel: "Cancel",
close: "Close",
decline: "Decline",
save: "Save",
submit: "Submit",
@ -216,6 +217,7 @@
},
},
cancel: "Abbrechen",
close: "Schließen",
decline: "Ablehnen",
save: "Speichern",
submit: "Senden",

View File

@ -1,7 +1,7 @@
import json
from datetime import datetime
from flask import flash, jsonify, redirect, render_template, request, url_for
from flask import Response, flash, jsonify, redirect, render_template, request, url_for
from flask_babelex import gettext
from flask_security import auth_required, current_user
from sqlalchemy.exc import SQLAlchemyError
@ -15,7 +15,7 @@ from project.access import (
get_admin_unit_members_with_permission,
has_access,
)
from project.dateutils import get_next_full_hour
from project.dateutils import create_icalendar, get_next_full_hour
from project.forms.event import CreateEventForm, DeleteEventForm, UpdateEventForm
from project.jsonld import DateTimeEncoder, get_sd_for_event_date
from project.models import (
@ -30,6 +30,7 @@ from project.models import (
PublicStatus,
)
from project.services.event import (
create_ical_events_for_event,
get_event_with_details_or_404,
get_meta_data,
get_significant_event_changes,
@ -43,6 +44,7 @@ from project.views.event_suggestion import send_event_suggestion_review_status_m
from project.views.utils import (
flash_errors,
flash_message,
get_calendar_links_for_event,
get_share_links,
handleSqlError,
send_mails,
@ -58,6 +60,7 @@ def event(event_id):
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)
calendar_links = get_calendar_links_for_event(event)
structured_datas = list()
for event_date in dates:
@ -75,6 +78,7 @@ def event(event_id):
user_rights=user_rights,
canonical_url=url_for("event", event_id=event_id, _external=True),
share_links=share_links,
calendar_links=calendar_links,
)
@ -434,3 +438,21 @@ def send_event_report_mails(event: Event, report: dict):
event=event,
report=report,
)
@app.route("/event/<int:id>/ical")
def event_ical(id):
event = get_event_with_details_or_404(id)
can_read_event_or_401(event)
ical_events = create_ical_events_for_event(event)
cal = create_icalendar()
for ical_event in ical_events:
cal.add_component(ical_event)
return Response(
cal.to_ical(),
mimetype="text/calendar",
headers={"Content-disposition": f"attachment; filename=event_{id}.ics"},
)

View File

@ -16,7 +16,11 @@ from project.services.event import (
)
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, get_calendar_links, get_share_links
from project.views.utils import (
flash_errors,
get_calendar_links_for_event_date,
get_share_links,
)
def prepare_event_date_form(form):
@ -51,7 +55,7 @@ def event_date(id):
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)
calendar_links = get_calendar_links_for_event_date(event_date)
return render_template(
"event_date/read.html",

View File

@ -1,9 +1,30 @@
from flask import render_template
from flask import Response, render_template
from project import app
from project.dateutils import create_icalendar
from project.models import AdminUnit
from project.services.admin_unit import create_ical_events_for_admin_unit
@app.route("/organizations")
@app.route("/organizations/<path:path>")
def organizations(path=None):
return render_template("organization/main.html")
@app.route("/organizations/<int:id>/ical")
def organization_ical(id):
admin_unit = AdminUnit.query.get_or_404(id)
cal = create_icalendar()
cal.add("x-wr-calname", admin_unit.name)
ical_events = create_ical_events_for_admin_unit(admin_unit)
for ical_event in ical_events:
cal.add_component(ical_event)
return Response(
cal.to_ical(),
mimetype="text/calendar",
headers={"Content-disposition": f"attachment; filename=organization_{id}.ics"},
)

View File

@ -12,7 +12,7 @@ from wtforms import FormField
from project import app, mail
from project.access import get_admin_unit_for_manage, get_admin_units_for_manage
from project.dateutils import berlin_tz, round_to_next_day
from project.models import EventAttendanceMode, EventDate
from project.models import Event, EventAttendanceMode, EventDate
from project.utils import get_place_str, strings_are_equal_ignoring_case
@ -197,7 +197,7 @@ def get_share_links(url: str, title: str) -> dict:
return share_links
def get_calendar_links(event_date: EventDate) -> dict:
def get_calendar_links_for_event_date(event_date: EventDate) -> dict:
calendar_links = dict()
url = url_for("event_date", id=event_date.id, _external=True)
@ -234,6 +234,12 @@ def get_calendar_links(event_date: EventDate) -> dict:
return calendar_links
def get_calendar_links_for_event(event: Event) -> dict:
calendar_links = dict()
calendar_links["ics"] = url_for("event_ical", id=event.id, _external=True)
return calendar_links
def get_invitation_access_result(email: str):
from project.services.user import find_user_by_email

View File

@ -29,7 +29,7 @@ from project.views.event_date import prepare_event_date_form
from project.views.utils import (
flash_errors,
flash_message,
get_calendar_links,
get_calendar_links_for_event_date,
get_pagination_urls,
get_share_links,
handleSqlError,
@ -81,7 +81,7 @@ def widget_event_date(au_short_name, id):
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)
calendar_links = get_calendar_links_for_event_date(event_date)
return render_template(
"widget/event_date/read.html",

View File

@ -185,11 +185,15 @@ class Seeder(object):
def create_admin_unit_member_event_verifier(self, admin_unit_id):
return self.create_admin_unit_member(admin_unit_id, ["event_verifier"])
def upsert_event_place(self, admin_unit_id, name):
def upsert_event_place(self, admin_unit_id, name, location=None):
from project.services.place import upsert_event_place
with self._app.app_context():
place = upsert_event_place(admin_unit_id, name)
if location:
place.location = location
self._db.session.commit()
place_id = place.id
@ -317,6 +321,7 @@ class Seeder(object):
co_organizer_ids=None,
description="Beschreibung",
tags="",
place_id=None,
):
from project.models import (
Event,
@ -332,7 +337,6 @@ class Seeder(object):
event.categories = [upsert_event_category("Other")]
event.name = name
event.description = description
event.event_place_id = self.upsert_default_event_place(admin_unit_id)
event.organizer_id = self.upsert_default_event_organizer(admin_unit_id)
event.external_link = external_link
event.ticket_link = ""
@ -340,6 +344,11 @@ class Seeder(object):
event.price_info = ""
event.attendance_mode = EventAttendanceMode.offline
if place_id:
event.event_place_id = place_id
else:
event.event_place_id = self.upsert_default_event_place(admin_unit_id)
date_definition = self.create_event_date_definition(
start, end, allday, recurrence_rule
)
@ -636,7 +645,13 @@ class Seeder(object):
)
def create_common_scenario(self):
from dateutil.relativedelta import relativedelta
from project.models import Location
with self._app.app_context():
now = self.get_now_by_minute()
# Admin with eventcally organisation
admin_id = self.create_user(
"admin@test.de", "MeinPasswortIstDasBeste", admin=True
@ -666,6 +681,24 @@ class Seeder(object):
verify=True,
)
self.create_event(marketing_admin_unit_id)
self.create_event(
marketing_admin_unit_id,
recurrence_rule="RRULE:FREQ=DAILY;COUNT=7",
name="Recurring",
start=now,
end=now + relativedelta(hours=1),
place_id=self.upsert_event_place(
marketing_admin_unit_id,
"MachmitHaus",
Location(
street="Markt 7",
postalCode="38640",
city="Goslar",
latitude=51.9077888,
longitude=10.4333312,
),
),
)
# Unverified Verein organisation
verein_admin_unit_id = self.create_admin_unit(

View File

@ -269,3 +269,41 @@ def test_get_events_fulltext(
for item in pagination.items:
assert item.id == event_ids[order[i]]
i = i + 1
def test_create_ical_events_for_event(client, app, db, utils, seeder):
user_id, admin_unit_id = seeder.setup_base()
event_id = seeder.create_event(admin_unit_id)
with app.app_context():
from project.models import Event, Location
from project.services.event import create_ical_events_for_event
event = Event.query.get(event_id)
event.description = "This is a fantastic event. Watch out!"
place = event.event_place
place.name = "MachMitHaus Goslar"
location = Location()
location.street = "Markt 7"
location.postalCode = "38640"
location.city = "Goslar"
location.latitude = 51.9077888
location.longitude = 10.4333312
place.location = location
db.session.commit()
with app.test_request_context():
ical_events = create_ical_events_for_event(event)
assert len(ical_events) == 1
ical_event = ical_events[0]
assert "DESCRIPTION" in ical_event
assert "GEO" in ical_event
assert "X-APPLE-STRUCTURED-LOCATION" in ical_event
ical = ical_event.to_ical()
assert ical

View File

@ -649,3 +649,63 @@ def test_report(seeder, utils):
url = utils.get_url("event_report", event_id=event_id)
utils.get_ok(url)
def test_ical(client, seeder, utils):
from project.dateutils import create_berlin_date
user_id, admin_unit_id = seeder.setup_base(log_in=False)
# Default
event_id = seeder.create_event(admin_unit_id, end=seeder.get_now_by_minute())
url = utils.get_url("event_ical", id=event_id)
utils.get_ok(url)
# Draft
draft_id = seeder.create_event(
admin_unit_id,
draft=True,
start=create_berlin_date(2020, 1, 2, 14, 30),
end=create_berlin_date(2020, 1, 3, 14, 30),
)
url = utils.get_url("event_ical", id=draft_id)
response = utils.get(url)
utils.assert_response_unauthorized(response)
utils.login()
utils.get_ok(url)
# Unverified
_, _, unverified_id = seeder.create_event_unverified()
url = utils.get_url("event_ical", id=unverified_id)
response = utils.get(url)
utils.assert_response_unauthorized(response)
# All-day single day
allday_id = seeder.create_event(
admin_unit_id, allday=True, start=create_berlin_date(2020, 1, 2, 14, 30)
)
url = utils.get_url("event_ical", id=allday_id)
response = utils.get_ok(url)
utils.assert_response_contains(response, "DTSTART;VALUE=DATE:20200102")
utils.assert_response_contains_not(response, "DTEND;VALUE=DATE:")
# All-day multiple days
allday_id = seeder.create_event(
admin_unit_id,
allday=True,
start=create_berlin_date(2020, 1, 2, 14, 30),
end=create_berlin_date(2020, 1, 3, 14, 30),
)
url = utils.get_url("event_ical", id=allday_id)
response = utils.get_ok(url)
utils.assert_response_contains(response, "DTSTART;VALUE=DATE:20200102")
utils.assert_response_contains(response, "DTEND;VALUE=DATE:20200104")
# Recurrence rule
event_with_recc_id = seeder.create_event(
admin_unit_id, recurrence_rule="RRULE:FREQ=DAILY;COUNT=7"
)
url = utils.get_url("event_ical", id=event_with_recc_id)
response = utils.get_ok(url)
utils.assert_response_contains(response, "FREQ=DAILY;COUNT=7")

View File

@ -0,0 +1,11 @@
def test_organizations(client, seeder, utils):
url = utils.get_url("organizations")
utils.get_ok(url)
def test_ical(client, seeder, utils):
user_id, admin_unit_id = seeder.setup_base(log_in=False)
seeder.create_event(admin_unit_id, end=seeder.get_now_by_minute())
url = utils.get_url("organization_ical", id=admin_unit_id)
utils.get_ok(url)

View File

@ -12,11 +12,6 @@ def test_up(app, utils):
utils.get_ok("up")
def test_organizations(client, seeder, utils):
url = utils.get_url("organizations")
utils.get_ok(url)
def test_tos(app, db, utils):
with app.app_context():
from project.services.admin import upsert_settings

View File

@ -46,7 +46,7 @@ def test_get_calendar_links(client, seeder, utils, app, db, mocker):
from project.dateutils import create_berlin_date
from project.models import Event, EventAttendanceMode
from project.services.event import update_event_dates_with_recurrence_rule
from project.views.utils import get_calendar_links
from project.views.utils import get_calendar_links_for_event_date
utils.mock_now(mocker, 2020, 1, 3)
@ -56,7 +56,7 @@ def test_get_calendar_links(client, seeder, utils, app, db, mocker):
date_definition.end = None
event.attendance_mode = EventAttendanceMode.online
event_date = event.dates[0]
links = get_calendar_links(event_date)
links = get_calendar_links_for_event_date(event_date)
assert "&location" not in links["google"]
# All-day single day
@ -66,7 +66,7 @@ def test_get_calendar_links(client, seeder, utils, app, db, mocker):
update_event_dates_with_recurrence_rule(event)
db.session.commit()
event_date = event.dates[0]
links = get_calendar_links(event_date)
links = get_calendar_links_for_event_date(event_date)
assert "&dates=20200102/20200103&" in links["google"]
# All-day multiple days
@ -76,5 +76,5 @@ def test_get_calendar_links(client, seeder, utils, app, db, mocker):
update_event_dates_with_recurrence_rule(event)
db.session.commit()
event_date = event.dates[0]
links = get_calendar_links(event_date)
links = get_calendar_links_for_event_date(event_date)
assert "&dates=20200102/20200104&" in links["google"]