Skip to content

Commit 01c3d07

Browse files
authored
feat: personalised ical feed (#1101)
* feat: calendar endpoint * feat: added button to copy personalised webcal link * feat: if participant is enrolled as reservist, activity is marked as such * chore: expanded database, processed TODOs and handled exceptiosn * chore: conform to linter * fix: expand test fixture * fix: whole-day activities are now properly formatted * fix: properly respond to HEAD request * refactor: move activity_to_event to activity model * chore: localised everything * chore: conform to linters * fix: normalised locales * feat: adds clarification on icalendar feed * feat: added disclaimer in activity template * fixup! feat: added disclaimer in activity template * fixup! fixup! feat: added disclaimer in activity template * fixup! Merge branch 'master' into feat/personalised-ical-feed * fixup! Merge branch 'master' into feat/personalised-ical-feed * fix: locale translation
1 parent 7166de0 commit 01c3d07

21 files changed

+292
-23
lines changed

Gemfile

+3
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ gem 'pg'
4949
# Full text search
5050
gem 'pg_search'
5151

52+
# iCalendar feeds
53+
gem 'icalendar'
54+
5255
group :production, :staging do
5356
gem 'sentry-raven'
5457
gem 'uglifier'

Gemfile.lock

+4
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,9 @@ GEM
151151
rails-i18n
152152
rainbow (>= 2.2.2, < 4.0)
153153
terminal-table (>= 1.5.1)
154+
icalendar (2.10.1)
155+
ice_cube (~> 0.16)
156+
ice_cube (0.16.4)
154157
image_processing (1.12.1)
155158
mini_magick (>= 4.9.5, < 5)
156159
ruby-vips (>= 2.0.17, < 3)
@@ -348,6 +351,7 @@ DEPENDENCIES
348351
i15r (~> 0.5.5)
349352
i18n-js
350353
i18n-tasks (~> 0.9.31)
354+
icalendar
351355
image_processing
352356
impressionist!
353357
listen

app/controllers/admin/members_controller.rb

-1
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,6 @@ def create
7575
impressionist(@member, 'nieuwe lid')
7676
redirect_to(@member)
7777
else
78-
7978
# If the member hasn't filled in a study, again show an empty field
8079
@member.educations.build(id: '-1') if @member.educations.empty?
8180

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
require 'icalendar_helper'
2+
3+
# Controller for all calendar related endpoints
4+
class Api::CalendarsController < ActionController::Base
5+
# supply the personalised iCal feed from the user
6+
def show
7+
@member = Member.find_by(calendar_id: params[:calendar_id])
8+
# member variable will be accessible in other methods as well now
9+
10+
unless @member # No member with the specified hash was found
11+
render(json: { error: I18n.t('calendars.errors.unkown_hash') }, status: :not_found)
12+
return
13+
end
14+
15+
# If the HTTP request is a HEAD type, return the headers only
16+
if request.head?
17+
head(:ok)
18+
return
19+
end
20+
21+
respond_to do |format|
22+
format.ics do
23+
send_data(create_personal_calendar,
24+
type: 'text/calendar',
25+
disposition: 'attachment',
26+
filename: "#{ @member.first_name }_#{ I18n.t('calendars.jargon.activities') }.ics")
27+
end
28+
end
29+
end
30+
31+
def index
32+
if current_user.nil?
33+
render(json: { error: I18n.t('calendars.errors.not_logged_in') }, status: :forbidden)
34+
return
35+
end
36+
37+
@member = Member.find(current_user.credentials_id)
38+
render(plain: url_for(action: 'show', calendar_id: @member.calendar_id), format: :ics)
39+
end
40+
41+
# Not exposed to API directly, but through #show
42+
def create_personal_calendar
43+
# Convert activities to events, and mark activities where the member is
44+
# is enrolled as reservist
45+
@reservist_activity_ids = @member.reservist_activities.ids
46+
events = @member.activities.map do |a|
47+
if @reservist_activity_ids.include?(a.id)
48+
a.name = "[#{ I18n.t('calendars.jargon.reservist').upcase }] #{ a.name }"
49+
end
50+
a.to_calendar_event(I18n.locale)
51+
end
52+
53+
# Return the calendar
54+
IcalendarHelper.create_calendar(events, @locale).to_ical
55+
end
56+
end

app/controllers/members/participants_controller.rb

-1
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,6 @@ def create
121121
participant_limit: @activity.participant_limit,
122122
participant_count: @activity.participants.count
123123
})
124-
return
125124
else
126125
@new_enrollment = Participant.new(
127126
member_id: @member.id,

app/javascript/src/members/activities/activities.js

+35-4
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,46 @@ import { Activity } from "./activity.js";
1010
var token, modal;
1111

1212
function copyICSToClipboard() {
13-
/* Link to copy */
14-
var copy_text =
15-
"https://calendar.google.com/calendar/ical/stickyutrecht.nl_thvhicj5ijouaacp1elsv1hceo%40group.calendar.google.com/public/basic.ics";
1613
new Clipboard("#copy-btn", {
1714
text: function () {
18-
return copy_text;
15+
return "https://calendar.google.com/calendar/ical/stickyutrecht.nl_thvhicj5ijouaacp1elsv1hceo%40group.calendar.google.com/public/basic.ics";
1916
},
2017
});
2118
}
2219

20+
function copyPersonalICSToClipboard() {
21+
fetch("/api/calendar/fetch")
22+
.then((response) => response.text())
23+
.then((icsFeed) => {
24+
new Clipboard("#copy-btn-personal", {
25+
text: function () {
26+
return icsFeed;
27+
},
28+
});
29+
})
30+
.catch((error) => {
31+
console.log(error);
32+
});
33+
} // TODO makes an API call even if the button is not pressed
34+
35+
document.getElementById("copy-btn-personal").addEventListener("click", (_) => {
36+
Swal.fire({
37+
title: I18n.t(
38+
"members.activities.calendar.confirm_understand_icalendar.title",
39+
),
40+
text: I18n.t(
41+
"members.activities.calendar.confirm_understand_icalendar.text",
42+
),
43+
icon: "warning",
44+
showCancelButton: false,
45+
confirmButtonText: I18n.t(
46+
"members.activities.calendar.confirm_understand_icalendar.confirm",
47+
),
48+
}).then((_) => {
49+
/* Do nothing, warning has been displayed and that's enough */
50+
});
51+
});
52+
2353
export function get_activity_container() {
2454
return $("#activity-container");
2555
}
@@ -244,6 +274,7 @@ $(document).on("ready page:load turbolinks:load", function () {
244274
initialize_enrollment();
245275
initialize_modal();
246276
copyICSToClipboard();
277+
copyPersonalICSToClipboard();
247278
});
248279

249280
document.addEventListener("turbolinks:load", function () {

app/models/activity.rb

+60-7
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
require 'icalendar' # https://github.com/icalendar/icalendar
2+
13
# Represents an activity in the database.
24
#:nodoc:
35
class Activity < ApplicationRecord
@@ -224,6 +226,34 @@ def end
224226
Activity.combine_dt(end_date, end_time)
225227
end
226228

229+
def whole_day?
230+
return !start_time && !end_time
231+
end
232+
233+
# Format a datetime in UTC for the iCalendar format
234+
def format_utc(datetime)
235+
datetime.utc.strftime('%Y%m%dT%H%M%SZ')
236+
end
237+
238+
# Format a datetime to a whole day for the iCalendar format
239+
def format_whole_day(datetime)
240+
datetime.utc.strftime('%Y%m%d')
241+
# For whole days, do not convert to UTC, because if 'start' is a date, it's
242+
# time will be 00:00:00 and will be converted to the previous day
243+
end
244+
245+
# Properly format the start datetime, depending on if the event is a whole day event or not
246+
def calendar_start
247+
normalised_start = start_time ? start : start.change(hour: 0, min: 0) # Won't have effect if whole_day
248+
return whole_day? ? format_whole_day(normalised_start) : format_utc(normalised_start)
249+
end
250+
251+
# Properly format the end datetime, depending on if the event is a whole day event or not
252+
def calendar_end
253+
normalised_end = end_time ? self.end : self.end.change(hour: 23, min: 59) # Won't have effect if whole_day
254+
return whole_day? ? format_whole_day(normalised_end + 1.day) : format_utc(normalised_end) # +1 day, end is exclusive
255+
end
256+
227257
def when_open
228258
Activity.combine_dt(open_date, open_time)
229259
end
@@ -360,19 +390,21 @@ def activity_url
360390
return "https://koala.svsticky.nl/activities/#{ id }"
361391
end
362392

363-
# pass along locale default to nil
393+
def description_localised(locale)
394+
return locale == :nl ? description_nl : description_en
395+
end
396+
397+
# This generates an URL representing a calendar activity template, filled with data from the koala activity
364398
def google_event(loc = nil)
365399
return nil if start.nil? || self.end.nil?
366400

367-
fmt_dt = ->(dt) { dt.utc.strftime('%Y%m%dT%H%M%SZ') }
368-
369401
loc = I18n.locale if loc.nil?
370-
description = "#{ activity_url }\n\n#{ loc == :nl ? description_nl : description_en }"
402+
disclaimer = "[#{ I18n.t('activerecord.attributes.activity.disclaimer') }]"
403+
description = "#{ activity_url }\n\n#{ description_localised(loc) }\n\n#{ disclaimer }"
371404
uri_name = URI.encode_www_form_component(name)
372405
uri_description = URI.encode_www_form_component(description)
373406
uri_location = URI.encode_www_form_component(location)
374-
calendar_end = end_time.nil? ? self.end.change(hour: 23, min: 59) : self.end
375-
return "https://www.google.com/calendar/render?action=TEMPLATE&text=#{ uri_name }&dates=#{ fmt_dt.call(start) }%2F#{ fmt_dt.call(calendar_end) }&details=#{ uri_description }&location=#{ uri_location }&sf=true&output=xml"
407+
return "https://www.google.com/calendar/render?action=TEMPLATE&text=#{ uri_name }&dates=#{ calendar_start }%2F#{ calendar_end }&details=#{ uri_description }&location=#{ uri_location }&sf=true&output=xml"
376408
end
377409

378410
# Add a message containing the Activity's id and name to the logs before deleting the activity.
@@ -394,7 +426,7 @@ def whatsapp_message(loc)
394426
location: location,
395427
price: pc,
396428
url: activity_url,
397-
description: loc == :nl ? description_nl : description_en,
429+
description: description_localised(loc),
398430
locale: loc)
399431
end
400432

@@ -409,4 +441,25 @@ def gen_time_string(loc)
409441

410442
return fmt_dt.call(start_date) + fmt_tm.call(start_time) + edt
411443
end
444+
445+
# Converts a sticky activity to an iCalendar event
446+
def to_calendar_event(locale)
447+
event = Icalendar::Event.new
448+
event.uid = id.to_s
449+
450+
if whole_day? # Adhire to the iCalendar spec
451+
event.dtstart = Icalendar::Values::Date.new(calendar_start)
452+
event.dtstart.ical_param("VALUE", "DATE")
453+
event.dtend = Icalendar::Values::Date.new(calendar_end)
454+
event.dtend.ical_param("VALUE", "DATE")
455+
else
456+
event.dtstart = calendar_start
457+
event.dtend = calendar_end
458+
end
459+
460+
event.summary = name
461+
event.description = description_localised(locale)
462+
event.location = location
463+
return event
464+
end
412465
end

app/models/member.rb

+6
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class Member < ApplicationRecord
1616
validates :emergency_phone_number, presence: true, if: :underage?
1717

1818
validates :email, presence: true, uniqueness: { case_sensitive: false }, format: { with: /\A.+@(?!(.+\.)*uu\.nl\z).+\..+\z/i }
19+
validates :calendar_id, presence: true, uniqueness: true
1920

2021
# An attr_accessor is basically a variable attached to the model but not stored in the database
2122
attr_accessor :require_student_id
@@ -203,6 +204,11 @@ def groups
203204
return groups.values
204205
end
205206

207+
# Whilst we cannot assign an id on creation, we can assign an id before validation, which is almost the same
208+
before_validation on: [:save, :create] do
209+
self.calendar_id = SecureRandom.uuid if calendar_id.blank?
210+
end
211+
206212
# Rails also has hooks you can hook on to the process of saving, updating or deleting. Here the join_date is automatically filled in on creating a new member
207213
# We also check for a duplicate study, and discard the duplicate if found.
208214
# (Not doing this would lead to a database constraint violation.)

app/views/members/activities/index.html.haml

+2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
= I18n.t 'members.activities.index.activities_calendar'
99
%button.btn.btn-secondary#copy-btn{:type => 'button'}
1010
= I18n.t 'members.activities.index.copy_ICS'
11+
%button.btn.btn-secondary#copy-btn-personal{:type => 'button'}
12+
= I18n.t 'members.activities.index.copy_ICS_personal'
1113
- else
1214
.alert.alert-warning= I18n.t('members.activities.index.no_activities')
1315

config/locales/en.yml

+11-1
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,10 @@ en:
3333
description: Description
3434
description_en: English description
3535
description_nl: Dutch description
36+
disclaimer: Data on this activity may be outdated, as it was addes as a one-time copy of the information given at that time. Up-to-date info can be found on Koala.
3637
end_date: Enddate
3738
end_time: Endtime
38-
google_event: Add to calendar
39+
google_event: Copy once to calendar
3940
is_alcoholic: Alcoholic(18+)
4041
is_enrollable: Enrollable
4142
is_freshmans: First year students
@@ -415,6 +416,15 @@ en:
415416
study: Study/Studies
416417
transactions: Transactions
417418
association_name: Study association Sticky
419+
calendars:
420+
errors:
421+
not_logged_in: Not logged in
422+
unkown_hash: Unkown calendar hash
423+
jargon:
424+
activities: activities
425+
reservist: reservist
426+
personalised_activities_calendar:
427+
name: Sticky Activities
418428
date:
419429
day_names:
420430
- Sunday

config/locales/members.en.yml

+8-2
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ en:
1616
unenroll: Unenroll
1717
update_info: Update info
1818
back_to_overview: Back to activity overview
19+
calendar:
20+
confirm_understand_icalendar:
21+
confirm: I understand
22+
text: Koala will maintain the icalendar feed you just copied, at all times. However, koala is only aware of activities for which you are enrolled through koala, and not trough some other platform (like Pretix or a Google form). Remember to add those activities to your calendar manually.
23+
title: Warning
1924
error:
2025
edit: Could not edit!
2126
enroll: Could not enroll!
@@ -25,15 +30,16 @@ en:
2530
full: FULL!
2631
index:
2732
activities_calendar: Activities calendar
28-
copy_ICS: Copy Webcal link
33+
copy_ICS: Copy Webcal link for all activitites
34+
copy_ICS_personal: Copy Personalised Webcal link
2935
no_activities: There are no activities for which you can enroll at the moment
3036
info:
3137
more_info: More info
3238
notes_mandatory: Extra info required!
3339
home:
3440
edit:
3541
board: the board
36-
board_only_change_info: Some data can't be edit by yourself (for example your name, date of birth and student number). If this needs to be updated please contact
42+
board_only_change_info: Some data can't be edited by yourself (for example your name, date of birth and student number). If this needs to be updated please contact
3743
download:
3844
activities: Activities
3945
address: Address

config/locales/members.nl.yml

+7-1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ nl:
1616
unenroll: Uitschrijven
1717
update_info: Update info
1818
back_to_overview: Terug naar overzicht
19+
calendar:
20+
confirm_understand_icalendar:
21+
confirm: Ik begrijp het
22+
text: Koala zal de icalendar feed die je zojuist hebt gekopieerd te allen tijde bijwerken. Echter, is koala alleen op de hoogte van activiteiten waarvoor je je via Koala hebt ingeschreven, en niet via een ander platform (zoals Pretix of een Google form). Vergeet niet om die activiteiten handmatig aan je agenda toe te voegen.
23+
title: Waarschuwing
1924
error:
2025
edit: Kon niet bijwerken!
2126
enroll: Kon niet inschrijven!
@@ -25,7 +30,8 @@ nl:
2530
full: VOL!
2631
index:
2732
activities_calendar: Activiteitenkalender
28-
copy_ICS: Kopieer Webcal link
33+
copy_ICS: Kopieer Webcal link voor alle activiteiten
34+
copy_ICS_personal: Kopieer gepersonaliseerde Webcal link
2935
no_activities: Er zijn op het moment geen activiteiten waar je je voor kunt inschrijven
3036
info:
3137
more_info: Meer info

0 commit comments

Comments
 (0)