Skip to content

Commit

Permalink
Reapply "feat: personalised ical feed (#1101)"
Browse files Browse the repository at this point in the history
This reverts commit 7a59268.
  • Loading branch information
SilasPeters committed Dec 24, 2024
1 parent 7a59268 commit ecaeb67
Show file tree
Hide file tree
Showing 21 changed files with 292 additions and 23 deletions.
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
67 changes: 60 additions & 7 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.utc.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,21 @@ def activity_url
return "https://koala.svsticky.nl/activities/#{ id }"
end

# pass along locale default to nil
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(loc = nil)
return nil if start.nil? || self.end.nil?

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

loc = I18n.locale if loc.nil?
description = "#{ activity_url }\n\n#{ loc == :nl ? description_nl : description_en }"
disclaimer = "[#{ I18n.t('activerecord.attributes.activity.disclaimer') }]"
description = "#{ activity_url }\n\n#{ description_localised(loc) }\n\n#{ disclaimer }"
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 +426,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 +441,25 @@ 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 = description_localised(locale)
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 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.
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, 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.
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 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.
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

0 comments on commit ecaeb67

Please sign in to comment.