From bfd0a8c24c55e2b775fa893597f12e24442532ca Mon Sep 17 00:00:00 2001 From: Daniel Grams Date: Thu, 30 Mar 2023 23:47:31 +0200 Subject: [PATCH] iCal for organization #393 --- project/services/admin_unit.py | 25 ++++ project/services/event.py | 127 +++++++++++++++----- project/static/vue/organization/read.vue.js | 46 +++++++ project/templates/_macros.html | 2 + project/templates/event/read.html | 2 +- project/templates/layout_vue.html | 2 + project/views/event.py | 26 +++- project/views/event_date.py | 8 +- project/views/organization.py | 23 +++- project/views/utils.py | 10 +- project/views/widget.py | 4 +- tests/seeder.py | 37 +++++- tests/services/test_event.py | 38 ++++++ tests/views/test_event.py | 60 +++++++++ tests/views/test_organization.py | 11 ++ tests/views/test_root.py | 5 - tests/views/test_utils.py | 8 +- 17 files changed, 382 insertions(+), 52 deletions(-) create mode 100644 tests/views/test_organization.py diff --git a/project/services/admin_unit.py b/project/services/admin_unit.py index 012951e..56e544d 100644 --- a/project/services/admin_unit.py +++ b/project/services/admin_unit.py @@ -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 diff --git a/project/services/event.py b/project/services/event.py index 96add7b..33c2bfd 100644 --- a/project/services/event.py +++ b/project/services/event.py @@ -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(): diff --git a/project/static/vue/organization/read.vue.js b/project/static/vue/organization/read.vue.js index 983e47c..4526dfb 100644 --- a/project/static/vue/organization/read.vue.js +++ b/project/static/vue/organization/read.vue.js @@ -38,6 +38,24 @@ const OrganizationRead = { {{ organization.location.postalCode }} {{ organization.location.city }} + + + + {{ $t("comp.icalExport") }} + + + + + + @@ -48,6 +66,26 @@ const OrganizationRead = { `, + 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")) + } }, }; diff --git a/project/templates/_macros.html b/project/templates/_macros.html index 8b3140c..36704e9 100644 --- a/project/templates/_macros.html +++ b/project/templates/_macros.html @@ -1513,7 +1513,9 @@ $('#allday').on('change', function() {