Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: finish ical feed #1145

Draft
wants to merge 6 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ gem 'pg'
# Full text search
gem 'pg_search'

# iCalendar feeds
gem 'icalendar'

group :production, :staging do
gem 'sentry-raven'
gem 'uglifier'
Expand Down
4 changes: 4 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,9 @@ GEM
rails-i18n
rainbow (>= 2.2.2, < 4.0)
terminal-table (>= 1.5.1)
icalendar (2.10.1)
ice_cube (~> 0.16)
ice_cube (0.16.4)
image_processing (1.12.1)
mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3)
Expand Down Expand Up @@ -348,6 +351,7 @@ DEPENDENCIES
i15r (~> 0.5.5)
i18n-js
i18n-tasks (~> 0.9.31)
icalendar
image_processing
impressionist!
listen
Expand Down
1 change: 0 additions & 1 deletion app/controllers/admin/members_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ def create
impressionist(@member, 'nieuwe lid')
redirect_to(@member)
else

# If the member hasn't filled in a study, again show an empty field
@member.educations.build(id: '-1') if @member.educations.empty?

Expand Down
56 changes: 56 additions & 0 deletions app/controllers/api/calendars_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
require 'icalendar_helper'

# Controller for all calendar related endpoints
class Api::CalendarsController < ActionController::Base
# supply the personalised iCal feed from the user
def show
@member = Member.find_by(calendar_id: params[:calendar_id])
# member variable will be accessible in other methods as well now

unless @member # No member with the specified hash was found
render(json: { error: I18n.t('calendars.errors.unkown_hash') }, status: :not_found)
return
end

# If the HTTP request is a HEAD type, return the headers only
if request.head?
head(:ok)
return
end

respond_to do |format|
format.ics do
send_data(create_personal_calendar,
type: 'text/calendar',
disposition: 'attachment',
filename: "#{ @member.first_name }_#{ I18n.t('calendars.jargon.activities') }.ics")
end
end
end

def index
if current_user.nil?
render(json: { error: I18n.t('calendars.errors.not_logged_in') }, status: :forbidden)
return
end

@member = Member.find(current_user.credentials_id)
render(plain: url_for(action: 'show', calendar_id: @member.calendar_id), format: :ics)
end

# Not exposed to API directly, but through #show
def create_personal_calendar
# Convert activities to events, and mark activities where the member is
# is enrolled as reservist
@reservist_activity_ids = @member.reservist_activities.ids
events = @member.activities.map do |a|
if @reservist_activity_ids.include?(a.id)
a.name = "[#{ I18n.t('calendars.jargon.reservist').upcase }] #{ a.name }"
end
a.to_calendar_event(I18n.locale)
end

# Return the calendar
IcalendarHelper.create_calendar(events, @locale).to_ical
end
end
1 change: 0 additions & 1 deletion app/controllers/members/participants_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,6 @@ def create
participant_limit: @activity.participant_limit,
participant_count: @activity.participants.count
})
return
else
@new_enrollment = Participant.new(
member_id: @member.id,
Expand Down
39 changes: 35 additions & 4 deletions app/javascript/src/members/activities/activities.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,46 @@ import { Activity } from "./activity.js";
var token, modal;

function copyICSToClipboard() {
/* Link to copy */
var copy_text =
"https://calendar.google.com/calendar/ical/stickyutrecht.nl_thvhicj5ijouaacp1elsv1hceo%40group.calendar.google.com/public/basic.ics";
new Clipboard("#copy-btn", {
text: function () {
return copy_text;
return "https://calendar.google.com/calendar/ical/stickyutrecht.nl_thvhicj5ijouaacp1elsv1hceo%40group.calendar.google.com/public/basic.ics";
},
});
}

function copyPersonalICSToClipboard() {
fetch("/api/calendar/fetch")
.then((response) => response.text())
.then((icsFeed) => {
new Clipboard("#copy-btn-personal", {
text: function () {
return icsFeed;
},
});
})
.catch((error) => {
console.log(error);
});
} // TODO makes an API call even if the button is not pressed

document.getElementById("copy-btn-personal").addEventListener("click", (_) => {
Swal.fire({
title: I18n.t(
"members.activities.calendar.confirm_understand_icalendar.title",
),
text: I18n.t(
"members.activities.calendar.confirm_understand_icalendar.text",
),
icon: "warning",
showCancelButton: false,
confirmButtonText: I18n.t(
"members.activities.calendar.confirm_understand_icalendar.confirm",
),
}).then((_) => {
/* Do nothing, warning has been displayed and that's enough */
});
});

export function get_activity_container() {
return $("#activity-container");
}
Expand Down Expand Up @@ -244,6 +274,7 @@ $(document).on("ready page:load turbolinks:load", function () {
initialize_enrollment();
initialize_modal();
copyICSToClipboard();
copyPersonalICSToClipboard();
});

document.addEventListener("turbolinks:load", function () {
Expand Down
75 changes: 67 additions & 8 deletions app/models/activity.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
require 'icalendar' # https://github.com/icalendar/icalendar

# Represents an activity in the database.
#:nodoc:
class Activity < ApplicationRecord
Expand Down Expand Up @@ -224,6 +226,34 @@ def end
Activity.combine_dt(end_date, end_time)
end

def whole_day?
return !start_time && !end_time
end

# Format a datetime in UTC for the iCalendar format
def format_utc(datetime)
datetime.utc.strftime('%Y%m%dT%H%M%SZ')
end

# Format a datetime to a whole day for the iCalendar format
def format_whole_day(datetime)
datetime.strftime('%Y%m%d')
# For whole days, do not convert to UTC, because if 'start' is a date, it's
# time will be 00:00:00 and will be converted to the previous day
end

# Properly format the start datetime, depending on if the event is a whole day event or not
def calendar_start
normalised_start = start_time ? start : start.change(hour: 0, min: 0) # Won't have effect if whole_day
return whole_day? ? format_whole_day(normalised_start) : format_utc(normalised_start)
end

# Properly format the end datetime, depending on if the event is a whole day event or not
def calendar_end
normalised_end = end_time ? self.end : self.end.change(hour: 23, min: 59) # Won't have effect if whole_day
return whole_day? ? format_whole_day(normalised_end + 1.day) : format_utc(normalised_end) # +1 day, end is exclusive
end

def when_open
Activity.combine_dt(open_date, open_time)
end
Expand Down Expand Up @@ -360,19 +390,25 @@ def activity_url
return "https://koala.svsticky.nl/activities/#{ id }"
end

# pass along locale default to nil
def google_event(loc = nil)
# The description of the activity in the specified locale
def description_localised(locale)
return locale == :nl ? description_nl : description_en
end

# This generates an URL representing a calendar activity template, filled with data from the koala activity
def google_event(locale = nil)
return nil if start.nil? || self.end.nil?

fmt_dt = ->(dt) { dt.utc.strftime('%Y%m%dT%H%M%SZ') }
locale = I18n.locale if locale.nil?

disclaimer = "[#{ I18n.t('activerecord.attributes.activity.disclaimer', deep_interpolation: true,
datetime: DateTime.current.utc.to_s) }]"
description = "#{ activity_url }\n\n#{ description_localised(locale) }\n\n#{ disclaimer }"

loc = I18n.locale if loc.nil?
description = "#{ activity_url }\n\n#{ loc == :nl ? description_nl : description_en }"
uri_name = URI.encode_www_form_component(name)
uri_description = URI.encode_www_form_component(description)
uri_location = URI.encode_www_form_component(location)
calendar_end = end_time.nil? ? self.end.change(hour: 23, min: 59) : self.end
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"
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"
end

# Add a message containing the Activity's id and name to the logs before deleting the activity.
Expand All @@ -394,7 +430,7 @@ def whatsapp_message(loc)
location: location,
price: pc,
url: activity_url,
description: loc == :nl ? description_nl : description_en,
description: description_localised(loc),
locale: loc)
end

Expand All @@ -409,4 +445,27 @@ def gen_time_string(loc)

return fmt_dt.call(start_date) + fmt_tm.call(start_time) + edt
end

# Converts a Sticky activity to an iCalendar event
def to_calendar_event(locale)
event = Icalendar::Event.new
event.uid = id.to_s

if whole_day? # Adhire to the iCalendar spec
event.dtstart = Icalendar::Values::Date.new(calendar_start)
event.dtstart.ical_param("VALUE", "DATE")
event.dtend = Icalendar::Values::Date.new(calendar_end)
event.dtend.ical_param("VALUE", "DATE")
else
event.dtstart = calendar_start
event.dtend = calendar_end
end

event.summary = name
event.description = "#{ activity_url }\r\n\r\n"\
"#{ description_localised(locale) }\r\n\r\n"\
"Last synced on: #{ DateTime.current.utc }"
event.location = location
return event
end
end
6 changes: 6 additions & 0 deletions app/models/member.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class Member < ApplicationRecord
validates :emergency_phone_number, presence: true, if: :underage?

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

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

# Whilst we cannot assign an id on creation, we can assign an id before validation, which is almost the same
before_validation on: [:save, :create] do
self.calendar_id = SecureRandom.uuid if calendar_id.blank?
end

# 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
# We also check for a duplicate study, and discard the duplicate if found.
# (Not doing this would lead to a database constraint violation.)
Expand Down
2 changes: 2 additions & 0 deletions app/views/members/activities/index.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
= I18n.t 'members.activities.index.activities_calendar'
%button.btn.btn-secondary#copy-btn{:type => 'button'}
= I18n.t 'members.activities.index.copy_ICS'
%button.btn.btn-secondary#copy-btn-personal{:type => 'button'}
= I18n.t 'members.activities.index.copy_ICS_personal'
- else
.alert.alert-warning= I18n.t('members.activities.index.no_activities')

Expand Down
12 changes: 11 additions & 1 deletion config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,10 @@ en:
description: Description
description_en: English description
description_nl: Dutch description
disclaimer: Data about this activity has been added as a one-time copy from what was known on %{datetime}, and as such could be outdated.
end_date: Enddate
end_time: Endtime
google_event: Add to calendar
google_event: Copy once to calendar
is_alcoholic: Alcoholic(18+)
is_enrollable: Enrollable
is_freshmans: First year students
Expand Down Expand Up @@ -415,6 +416,15 @@ en:
study: Study/Studies
transactions: Transactions
association_name: Study association Sticky
calendars:
errors:
not_logged_in: Not logged in
unkown_hash: Unkown calendar hash
jargon:
activities: activities
reservist: reservist
personalised_activities_calendar:
name: Sticky Activities
date:
day_names:
- Sunday
Expand Down
10 changes: 8 additions & 2 deletions config/locales/members.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ en:
unenroll: Unenroll
update_info: Update info
back_to_overview: Back to activity overview
calendar:
confirm_understand_icalendar:
confirm: I understand
text: Koala will maintain the icalendar feed you just copied to your clipboard, at all times. However, koala is only aware of activities for which you are enrolled through koala, and not through some other platform (like Pretix or a Google form). Remember to add those activities to your calendar manually.
title: Warning
error:
edit: Could not edit!
enroll: Could not enroll!
Expand All @@ -25,15 +30,16 @@ en:
full: FULL!
index:
activities_calendar: Activities calendar
copy_ICS: Copy Webcal link
copy_ICS: Copy Webcal link for all activitites
copy_ICS_personal: Copy Personalised Webcal link
no_activities: There are no activities for which you can enroll at the moment
info:
more_info: More info
notes_mandatory: Extra info required!
home:
edit:
board: the board
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
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
download:
activities: Activities
address: Address
Expand Down
8 changes: 7 additions & 1 deletion config/locales/members.nl.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ nl:
unenroll: Uitschrijven
update_info: Update info
back_to_overview: Terug naar overzicht
calendar:
confirm_understand_icalendar:
confirm: Ik begrijp het
text: Koala zal de icalendar feed die je zojuist hebt gekopieerd naar je klembord ten aller 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.
title: Waarschuwing
error:
edit: Kon niet bijwerken!
enroll: Kon niet inschrijven!
Expand All @@ -25,7 +30,8 @@ nl:
full: VOL!
index:
activities_calendar: Activiteitenkalender
copy_ICS: Kopieer Webcal link
copy_ICS: Kopieer Webcal link voor alle activiteiten
copy_ICS_personal: Kopieer gepersonaliseerde Webcal link
no_activities: Er zijn op het moment geen activiteiten waar je je voor kunt inschrijven
info:
more_info: Meer info
Expand Down
Loading
Loading