mirror of
https://github.com/discourse/discourse.git
synced 2026-09-05 04:40:41 -05:00
Moves the plugin directory and associated files, along with updating various import references.
714 lines
23 KiB
Ruby
714 lines
23 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
module DiscoursePostEvent
|
|
class Event < ActiveRecord::Base
|
|
PUBLIC_GROUP = "trust_level_0"
|
|
# Keep in sync with the constants of the same name in the
|
|
# DiscoursePostEventEvent JS model.
|
|
EARLY_ACCESS_MINUTES = 30
|
|
GRACE_PERIOD_MINUTES = 10
|
|
MIN_NAME_LENGTH = 5
|
|
MAX_NAME_LENGTH = 255
|
|
MAX_DESCRIPTION_LENGTH = 1000
|
|
DEFAULT_TIMEZONE = "UTC"
|
|
|
|
self.table_name = "discourse_post_event_events"
|
|
self.ignored_columns = %w[starts_at ends_at]
|
|
|
|
has_many :event_dates, dependent: :destroy
|
|
# this is a cross plugin dependency, only called if chat is enabled
|
|
belongs_to :chat_channel, class_name: "Chat::Channel"
|
|
has_many :invitees, foreign_key: :post_id, dependent: :delete_all
|
|
belongs_to :post, foreign_key: :id
|
|
belongs_to :image_upload, class_name: "Upload", optional: true
|
|
has_many :upload_references, as: :target, dependent: :destroy
|
|
|
|
scope :visible, -> { where(deleted_at: nil) }
|
|
scope :open, -> { where(closed: false) }
|
|
|
|
before_validation :reset_invalid_livestream
|
|
before_save :chat_channel_sync
|
|
# prepend so it runs before `dependent: :delete_all` wipes the invitees
|
|
before_destroy :reset_invitees_topic_tracking, prepend: true
|
|
after_commit :create_livestream_chat_channel, on: %i[create update]
|
|
after_commit :enqueue_warm_livestream_onebox, on: %i[create update]
|
|
after_commit :destroy_topic_custom_field, on: %i[destroy]
|
|
after_commit :create_or_update_event_date, on: %i[create update]
|
|
after_save do
|
|
if saved_change_to_image_upload_id?
|
|
UploadReference.ensure_exist!(upload_ids: [image_upload_id], target: self)
|
|
end
|
|
end
|
|
|
|
validate :raw_invitees_are_groups
|
|
validates :original_starts_at, presence: true
|
|
validates :name,
|
|
length: {
|
|
in: MIN_NAME_LENGTH..MAX_NAME_LENGTH,
|
|
},
|
|
unless: ->(event) { event.name.blank? }
|
|
validates :description, length: { maximum: MAX_DESCRIPTION_LENGTH }
|
|
validates :max_attendees, numericality: { only_integer: true, greater_than: 0, allow_nil: true }
|
|
|
|
validate :raw_invitees_length
|
|
validate :ends_before_start
|
|
validate :allowed_custom_fields
|
|
|
|
def self.attributes_protected_by_default
|
|
super - %w[id]
|
|
end
|
|
|
|
def reset_invalid_livestream
|
|
return unless livestream?
|
|
|
|
self.livestream = false unless allowed_livestream_url? && post&.is_first_post?
|
|
end
|
|
|
|
def allowed_livestream_url?
|
|
DiscourseCalendar::Livestream::AllowedHosts.allows_url?(livestream_url)
|
|
end
|
|
|
|
def create_livestream_chat_channel
|
|
return unless livestream? && SiteSetting.chat_enabled
|
|
return unless post&.is_first_post?
|
|
return if post.topic.blank?
|
|
|
|
DiscourseCalendar::Livestream.handle_topic_chat_channel_creation(post.topic)
|
|
end
|
|
|
|
def has_cached_livestream_onebox?
|
|
livestream? && livestream_url.present? && Oneboxer.cached_onebox(livestream_url).present?
|
|
end
|
|
|
|
# Happens after the event record is saved to prepare the onebox
|
|
def enqueue_warm_livestream_onebox
|
|
return if !livestream? || livestream_url.blank?
|
|
return if !saved_change_to_livestream? && !saved_change_to_location? && !saved_change_to_url?
|
|
return if has_cached_livestream_onebox?
|
|
Jobs.enqueue(:warm_livestream_onebox, event_id: id, url: livestream_url)
|
|
end
|
|
|
|
# Fetches the onebox and tells clients to re-render the post with it. Only
|
|
# ever called from the background job, never during a request.
|
|
def warm_livestream_onebox!
|
|
return if has_cached_livestream_onebox?
|
|
Oneboxer.onebox(livestream_url)
|
|
post&.publish_change_to_clients!(:revised)
|
|
end
|
|
|
|
def destroy_topic_custom_field
|
|
if post && post.is_first_post?
|
|
TopicCustomField.where(topic_id: post.topic_id, name: TOPIC_POST_EVENT_STARTS_AT).delete_all
|
|
|
|
TopicCustomField.where(topic_id: post.topic_id, name: TOPIC_POST_EVENT_ENDS_AT).delete_all
|
|
|
|
TopicCustomField.where(topic_id: post.topic_id, name: TOPIC_POST_EVENT_ALL_DAY).delete_all
|
|
end
|
|
end
|
|
|
|
def create_or_update_event_date
|
|
starts_at_changed = saved_change_to_original_starts_at
|
|
ends_at_changed = saved_change_to_original_ends_at
|
|
|
|
return if !starts_at_changed && !ends_at_changed
|
|
|
|
set_next_date
|
|
end
|
|
|
|
def set_next_date
|
|
return if closed
|
|
|
|
next_date_result = calculate_next_date
|
|
|
|
return event_dates.update_all(finished_at: Time.current) if next_date_result.nil?
|
|
|
|
starts_at, ends_at = next_date_result
|
|
finish_previous_event_dates(starts_at) if dates_changed?
|
|
upsert_event_date(starts_at, ends_at)
|
|
reset_invitee_notifications
|
|
notify_if_new_event
|
|
publish_update!
|
|
end
|
|
|
|
def set_topic_bump
|
|
return if closed
|
|
|
|
date = nil
|
|
|
|
return if reminders.blank? || starts_at.nil?
|
|
reminders
|
|
.split(",")
|
|
.each do |reminder|
|
|
type, value, unit = reminder.split(".")
|
|
next if type != "bumpTopic" || !validate_reminder_unit(unit)
|
|
date = starts_at - value.to_i.public_send(unit)
|
|
break
|
|
end
|
|
|
|
return if date.blank?
|
|
Jobs.enqueue(:discourse_post_event_bump_topic, topic_id: post.topic_id, date: date.iso8601)
|
|
end
|
|
|
|
def validate_reminder_unit(input)
|
|
ActiveSupport::Duration::PARTS.any? { |part| part.to_s == input }
|
|
end
|
|
|
|
def expired?
|
|
if recurring?
|
|
return false if recurrence_until.nil?
|
|
return Time.current > recurrence_until
|
|
end
|
|
|
|
return true if starts_at.nil?
|
|
(ends_at || starts_at.end_of_day) <= Time.now
|
|
end
|
|
|
|
# Mirrors `currentlyWithinEventTimeframe` in the DiscoursePostEventEvent JS
|
|
# model: joining opens EARLY_ACCESS_MINUTES before the start and, when the
|
|
# event has an end time, closes GRACE_PERIOD_MINUTES after it.
|
|
def currently_within_event_timeframe?
|
|
return false if starts_at.nil?
|
|
|
|
now = Time.zone.now
|
|
|
|
if all_day
|
|
opens_at = starts_at.beginning_of_day
|
|
closes_at = starts_at.end_of_day
|
|
return opens_at <= now && now <= closes_at
|
|
end
|
|
|
|
return false if now < starts_at - EARLY_ACCESS_MINUTES.minutes
|
|
ends_at.nil? || now < ends_at + GRACE_PERIOD_MINUTES.minutes
|
|
end
|
|
|
|
def starts_at
|
|
return nil if recurring? && recurrence_until.present? && recurrence_until < Time.current
|
|
current_event_date&.starts_at || original_starts_at
|
|
end
|
|
|
|
def ends_at
|
|
return nil if recurring? && recurrence_until.present? && recurrence_until < Time.current
|
|
current_event_date&.ends_at || original_ends_at
|
|
end
|
|
|
|
def current_event_date
|
|
if association(:event_dates).loaded?
|
|
pending = event_dates.select { |d| d.finished_at.nil? }
|
|
pending.max_by(&:starts_at) || event_dates.max_by { |d| [d.updated_at, d.id] }
|
|
else
|
|
event_dates.current_first.first
|
|
end
|
|
end
|
|
|
|
def on_going_event_invitees
|
|
return [] if starts_at.nil? # Can't determine ongoing status without start time
|
|
if !ends_at && starts_at < Time.now && (!all_day || starts_at.end_of_day <= Time.now)
|
|
return []
|
|
end
|
|
|
|
if ends_at
|
|
extended_ends_at =
|
|
ends_at + SiteSetting.discourse_post_event_edit_notifications_time_extension.minutes
|
|
return [] if !(starts_at..extended_ends_at).cover?(Time.now)
|
|
end
|
|
|
|
invitees.where(status: DiscoursePostEvent::Invitee.statuses[:going])
|
|
end
|
|
|
|
def raw_invitees_length
|
|
if raw_invitees && raw_invitees.length > 10
|
|
errors.add(
|
|
:base,
|
|
I18n.t("discourse_post_event.errors.models.event.raw_invitees_length", count: 10),
|
|
)
|
|
end
|
|
end
|
|
|
|
def raw_invitees_are_groups
|
|
return if raw_invitees.blank?
|
|
|
|
non_group_invitees = raw_invitees - Group.where(name: raw_invitees).pluck(:name)
|
|
return if non_group_invitees.blank?
|
|
|
|
if User.where(username: non_group_invitees).exists?
|
|
errors.add(
|
|
:base,
|
|
I18n.t("discourse_post_event.errors.models.event.raw_invitees.only_group"),
|
|
)
|
|
end
|
|
end
|
|
|
|
def ends_before_start
|
|
if original_starts_at && original_ends_at && original_starts_at >= original_ends_at
|
|
errors.add(
|
|
:base,
|
|
I18n.t("discourse_post_event.errors.models.event.ends_at_before_starts_at"),
|
|
)
|
|
end
|
|
end
|
|
|
|
def allowed_custom_fields
|
|
allowed_custom_fields = SiteSetting.discourse_post_event_allowed_custom_fields.split("|")
|
|
custom_fields.each do |key, value|
|
|
if !allowed_custom_fields.include?(key)
|
|
errors.add(
|
|
:base,
|
|
I18n.t("discourse_post_event.errors.models.event.custom_field_is_invalid", field: key),
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
def create_invitees(attrs)
|
|
timestamp = Time.now
|
|
attrs.map! do |attr|
|
|
{ post_id: id, created_at: timestamp, updated_at: timestamp }.merge(attr)
|
|
end
|
|
result = invitees.insert_all!(attrs)
|
|
|
|
# batch event does not call callback
|
|
ChatChannelSync.sync(self) if chat_enabled?
|
|
|
|
result
|
|
end
|
|
|
|
def notify_invitees!(predefined_attendance: false)
|
|
invitees
|
|
.where(notified: false)
|
|
.find_each do |invitee|
|
|
create_notification!(invitee.user, post, predefined_attendance: predefined_attendance)
|
|
invitee.update!(notified: true)
|
|
end
|
|
end
|
|
|
|
def notify_missing_invitees!
|
|
missing_users.each { |user| create_notification!(user, post) } if private?
|
|
end
|
|
|
|
def create_notification!(user, post, predefined_attendance: false)
|
|
return if post.event.starts_at.nil? || post.event.starts_at < Time.current
|
|
return if !Guardian.new(user).can_see?(post)
|
|
|
|
message =
|
|
if predefined_attendance
|
|
"discourse_post_event.notifications.invite_user_predefined_attendance_notification"
|
|
else
|
|
"discourse_post_event.notifications.invite_user_notification"
|
|
end
|
|
|
|
attrs = {
|
|
notification_type: Notification.types[:event_invitation] || Notification.types[:custom],
|
|
topic_id: post.topic_id,
|
|
post_number: post.post_number,
|
|
data: {
|
|
user_id: user.id,
|
|
topic_title: name || post.topic.title,
|
|
display_username: post.user.username,
|
|
message: message,
|
|
event_name: name,
|
|
}.to_json,
|
|
}
|
|
|
|
user.notifications.consolidate_or_create!(attrs)
|
|
end
|
|
|
|
def ongoing?
|
|
return false if closed || expired? || starts_at.nil?
|
|
finishes_at = ends_at || starts_at.end_of_day
|
|
(starts_at..finishes_at).cover?(Time.now)
|
|
end
|
|
|
|
def going_count
|
|
invitees.where(status: Invitee.statuses[:going]).count
|
|
end
|
|
|
|
def at_capacity?
|
|
return false if max_attendees.blank?
|
|
going_count >= max_attendees
|
|
end
|
|
|
|
def self.statuses
|
|
@statuses ||= Enum.new(standalone: 0, public: 1, private: 2)
|
|
end
|
|
|
|
def public?
|
|
status == Event.statuses[:public]
|
|
end
|
|
|
|
def standalone?
|
|
status == Event.statuses[:standalone]
|
|
end
|
|
|
|
def private?
|
|
status == Event.statuses[:private]
|
|
end
|
|
|
|
def recurring?
|
|
recurrence.present?
|
|
end
|
|
|
|
def most_likely_going(limit = SiteSetting.displayed_invitees_limit)
|
|
going = invitees.order(%i[status created_at user_id]).limit(limit)
|
|
|
|
if private? && going.count < limit
|
|
# invitees are only created when an attendance is set
|
|
# so we create a dummy invitee object with only what's needed for serializer
|
|
going =
|
|
going +
|
|
missing_users(going.pluck(:user_id))
|
|
.limit(limit - going.count)
|
|
.map { |user| Invitee.new(user: user, post_id: id) }
|
|
end
|
|
|
|
going
|
|
end
|
|
|
|
def publish_update!
|
|
post.publish_message!("/discourse-post-event/#{post.topic_id}", id: id)
|
|
end
|
|
|
|
def fetch_users
|
|
@fetched_users ||= Invitee.extract_uniq_usernames(raw_invitees)
|
|
end
|
|
|
|
def enforce_private_invitees!
|
|
pruned = invitees.where.not(user_id: fetch_users.select(:id))
|
|
pruned_user_ids = pruned.pluck(:user_id)
|
|
pruned.delete_all
|
|
Invitee.reset_topic_tracking!(user_ids: pruned_user_ids, topic_id: post.topic_id)
|
|
unfollow_livestream_chat(pruned_user_ids)
|
|
end
|
|
|
|
# Unfollow users from the livestream chat channel once they are no longer
|
|
# attending (e.g. pruned when a private event's invited groups change). Chat
|
|
# following tracks attendance, so removed attendees should not keep the
|
|
# channel in their chat list.
|
|
def unfollow_livestream_chat(user_ids)
|
|
return if user_ids.blank?
|
|
|
|
channel = post.topic.topic_chat_channel&.chat_channel
|
|
return if channel.nil?
|
|
|
|
manager = Chat::ChannelMembershipManager.new(channel)
|
|
User
|
|
.where(id: user_ids)
|
|
.find_each do |user|
|
|
membership = manager.unfollow(user)
|
|
next if membership.nil?
|
|
DiscourseCalendar::Livestream.publish_livestream_chat_status(membership, user:)
|
|
end
|
|
end
|
|
|
|
def user_in_invited_group?(user, group_names: nil)
|
|
return false if user.blank? || raw_invitees.blank?
|
|
|
|
return (Array(raw_invitees) & Array(group_names)).any? unless group_names.nil?
|
|
|
|
GroupUser.where(
|
|
user_id: user.id,
|
|
group_id: Group.where(name: raw_invitees).select(:id),
|
|
).exists?
|
|
end
|
|
|
|
# Unlike can_user_update_attendance?, this stays true after the event
|
|
# closes or expires so attendees keep access to the livestream chat.
|
|
#
|
|
# Callers serializing many events at once (e.g. the chat channel list) can
|
|
# pass preloaded group_names to avoid a per-event query.
|
|
def can_access_livestream_chat?(user, group_names: nil)
|
|
return true if !private?
|
|
return false if user.blank?
|
|
return true if user.admin?
|
|
|
|
user_in_invited_group?(user, group_names:)
|
|
end
|
|
|
|
def can_user_update_attendance?(user, group_names: nil)
|
|
return false if user.blank? || closed || expired?
|
|
return true if public?
|
|
|
|
private? && user_in_invited_group?(user, group_names:)
|
|
end
|
|
|
|
def sync_image_to_post_and_topic(generate_thumbnails: false)
|
|
return unless image_upload_id
|
|
|
|
post.update_column(:image_upload_id, image_upload_id)
|
|
if post.is_first_post?
|
|
post.topic.update_column(:image_upload_id, image_upload_id)
|
|
if generate_thumbnails
|
|
extra_sizes =
|
|
ThemeModifierHelper.new(
|
|
theme_ids: Theme.user_selectable.pluck(:id),
|
|
).topic_thumbnail_sizes
|
|
post.topic.generate_thumbnails!(extra_sizes: extra_sizes)
|
|
end
|
|
end
|
|
end
|
|
|
|
def self.handle_post_event_webhooks(post, event_before)
|
|
had_event_before = event_before.present?
|
|
|
|
if post.event && had_event_before
|
|
WebHook.enqueue_calendar_event_hooks(:calendar_event_updated, post.event)
|
|
elsif post.event && !had_event_before
|
|
WebHook.enqueue_calendar_event_hooks(:calendar_event_created, post.event)
|
|
elsif !post.event && had_event_before
|
|
payload = WebHook.build_calendar_event_payload(event_before)
|
|
WebHook.enqueue_calendar_event_hooks(:calendar_event_destroyed, event_before, payload)
|
|
end
|
|
end
|
|
|
|
def missing_users(excluded_ids = invitees.select(:user_id))
|
|
users = User.real.activated.not_silenced.not_suspended.not_staged
|
|
|
|
if raw_invitees.present?
|
|
user_ids =
|
|
users
|
|
.joins(:groups)
|
|
.where("groups.name" => raw_invitees)
|
|
.where.not(id: excluded_ids)
|
|
.select(:id)
|
|
User.where(id: user_ids)
|
|
elsif private?
|
|
User.none
|
|
else
|
|
users.where.not(id: excluded_ids)
|
|
end
|
|
end
|
|
|
|
SUGGESTED_USERS_LIMIT = 10
|
|
|
|
# Users that could be invited to this event, ranked by how closely their
|
|
# username matches +filter+ (exact match first). Already-invited users are
|
|
# excluded, optionally narrowed to a given attendance +type+.
|
|
def suggested_users(filter, type: nil)
|
|
excluded = type ? invitees.with_status(type) : invitees
|
|
|
|
missing_users(excluded.select(:user_id))
|
|
.where(
|
|
"LOWER(username) LIKE :filter",
|
|
filter: "%#{User.sanitize_sql_like(filter.downcase)}%",
|
|
)
|
|
.order(
|
|
DB.sql_fragment(
|
|
"CASE WHEN LOWER(username) = ? THEN 0 ELSE 1 END ASC, LOWER(username) ASC",
|
|
filter.downcase,
|
|
),
|
|
)
|
|
.limit(SUGGESTED_USERS_LIMIT)
|
|
end
|
|
|
|
def update_with_params!(params)
|
|
case params[:status] ? params[:status].to_i : status
|
|
when Event.statuses[:private]
|
|
if params.key?(:raw_invitees)
|
|
params = params.merge(raw_invitees: Array(params[:raw_invitees]) - [PUBLIC_GROUP])
|
|
else
|
|
params = params.merge(raw_invitees: Array(raw_invitees) - [PUBLIC_GROUP])
|
|
end
|
|
update!(params)
|
|
enforce_private_invitees!
|
|
when Event.statuses[:public]
|
|
update!(params.merge(raw_invitees: [PUBLIC_GROUP]))
|
|
when Event.statuses[:standalone]
|
|
update!(params.merge(raw_invitees: []))
|
|
invitees.destroy_all
|
|
end
|
|
|
|
publish_update!
|
|
end
|
|
|
|
def chat_channel_sync
|
|
if chat_enabled && chat_channel_id.blank? && post.last_editor_id.present?
|
|
DiscoursePostEvent::ChatChannelSync.sync(
|
|
self,
|
|
guardian: Guardian.new(User.find_by(id: post.last_editor_id)),
|
|
)
|
|
end
|
|
end
|
|
|
|
def calculate_next_date
|
|
if recurrence.blank? || original_starts_at > Time.current
|
|
return original_starts_at, original_ends_at
|
|
end
|
|
|
|
next_starts_at = calculate_next_recurring_date
|
|
return nil unless next_starts_at
|
|
|
|
next_ends_at =
|
|
if original_ends_at
|
|
next_starts_at + (original_ends_at - original_starts_at)
|
|
elsif all_day
|
|
next_starts_at.end_of_day
|
|
else
|
|
next_starts_at + 3600
|
|
end
|
|
[next_starts_at, next_ends_at]
|
|
end
|
|
|
|
def calculate_next_occurrence_from(from_time)
|
|
return nil if recurrence.blank?
|
|
if original_starts_at > from_time
|
|
return { starts_at: original_starts_at, ends_at: original_ends_at }
|
|
end
|
|
|
|
next_starts_at = calculate_next_recurring_date_from(from_time)
|
|
return nil unless next_starts_at
|
|
|
|
next_ends_at =
|
|
if original_ends_at
|
|
next_starts_at + (original_ends_at - original_starts_at)
|
|
elsif all_day
|
|
next_starts_at.end_of_day
|
|
else
|
|
next_starts_at + 3600
|
|
end
|
|
{ starts_at: next_starts_at, ends_at: next_ends_at }
|
|
end
|
|
|
|
def duration
|
|
return nil unless original_starts_at
|
|
|
|
duration_seconds = original_ends_at ? original_ends_at - original_starts_at : 3600
|
|
hours = (duration_seconds / 3600)
|
|
minutes = ((duration_seconds % 3600) / 60)
|
|
seconds = (duration_seconds % 60)
|
|
|
|
sprintf("%02d:%02d:%02d", hours, minutes, seconds)
|
|
end
|
|
|
|
def rrule_timezone
|
|
timezone || DEFAULT_TIMEZONE
|
|
end
|
|
|
|
def livestream_url
|
|
location || url
|
|
end
|
|
|
|
def is_zoom_livestream?
|
|
return false unless SiteSetting.livestream_zoom_enabled
|
|
return false unless livestream?
|
|
DiscourseCalendar::Livestream::ZoomUrlParser.zoom_url?(livestream_url)
|
|
end
|
|
|
|
private
|
|
|
|
def reset_invitees_topic_tracking
|
|
topic_id = post&.topic_id
|
|
return if topic_id.nil?
|
|
|
|
Invitee.reset_topic_tracking!(user_ids: invitees.pluck(:user_id), topic_id:)
|
|
end
|
|
|
|
def dates_changed?
|
|
saved_change_to_original_starts_at || saved_change_to_original_ends_at
|
|
end
|
|
|
|
def finish_previous_event_dates(current_starts_at)
|
|
existing_date = event_dates.find_by(starts_at: current_starts_at)
|
|
event_dates
|
|
.where.not(id: existing_date&.id)
|
|
.where(finished_at: nil)
|
|
.update_all(finished_at: Time.current)
|
|
end
|
|
|
|
def upsert_event_date(starts_at, ends_at)
|
|
finished_at = ends_at && ends_at < Time.current ? ends_at : nil
|
|
|
|
existing_date = event_dates.find_by(starts_at:)
|
|
|
|
if existing_date
|
|
# Only update if something actually changed
|
|
unless existing_date.ends_at == ends_at && existing_date.finished_at == finished_at
|
|
existing_date.update!(ends_at:, finished_at:)
|
|
end
|
|
else
|
|
event_dates.create!(starts_at:, ends_at:, finished_at:)
|
|
end
|
|
end
|
|
|
|
def reset_invitee_notifications
|
|
invitees.where(
|
|
"status != :going OR recurring = FALSE",
|
|
going: Invitee.statuses[:going],
|
|
).update_all(status: nil, notified: false, recurring: false)
|
|
end
|
|
|
|
def notify_if_new_event
|
|
is_generating_future_recurrence = recurrence.present? && original_starts_at <= Time.current
|
|
|
|
unless is_generating_future_recurrence
|
|
notify_invitees!
|
|
notify_missing_invitees!
|
|
end
|
|
end
|
|
|
|
def calculate_next_recurring_date
|
|
timezone_starts_at = original_starts_at.in_time_zone(timezone)
|
|
timezone_recurrence_until = recurrence_until&.in_time_zone(timezone)
|
|
|
|
RRuleGenerator.generate(
|
|
starts_at: timezone_starts_at,
|
|
timezone: rrule_timezone,
|
|
recurrence: recurrence,
|
|
recurrence_until: timezone_recurrence_until,
|
|
dtstart: timezone_starts_at,
|
|
).first
|
|
end
|
|
|
|
def calculate_next_recurring_date_from(from_time)
|
|
timezone_starts_at = original_starts_at.in_time_zone(timezone)
|
|
timezone_recurrence_until = recurrence_until&.in_time_zone(timezone)
|
|
from_time_in_tz = from_time.in_time_zone(timezone)
|
|
|
|
RRule::Rule
|
|
.new(
|
|
RRuleConfigurator.rule(
|
|
recurrence_until: timezone_recurrence_until,
|
|
recurrence: recurrence,
|
|
starts_at: timezone_starts_at,
|
|
),
|
|
dtstart: timezone_starts_at,
|
|
tzid: rrule_timezone,
|
|
)
|
|
.between(from_time_in_tz - 1.hour, from_time_in_tz + 1.year)
|
|
.find { |date| date >= from_time_in_tz }
|
|
end
|
|
end
|
|
end
|
|
|
|
# == Schema Information
|
|
#
|
|
# Table name: discourse_post_event_events
|
|
#
|
|
# id :bigint not null, primary key
|
|
# all_day :boolean default(FALSE), not null
|
|
# chat_enabled :boolean default(FALSE), not null
|
|
# closed :boolean default(FALSE), not null
|
|
# custom_fields :jsonb not null
|
|
# deleted_at :datetime
|
|
# description :string(1000)
|
|
# livestream :boolean default(FALSE), not null
|
|
# location :string(1000)
|
|
# max_attendees :integer
|
|
# minimal :boolean
|
|
# name :string
|
|
# original_ends_at :datetime
|
|
# original_starts_at :datetime not null
|
|
# raw_invitees :string is an Array
|
|
# recurrence :string
|
|
# recurrence_until :datetime
|
|
# reminders :string
|
|
# show_local_time :boolean default(FALSE), not null
|
|
# status :integer default(0), not null
|
|
# timezone :string
|
|
# url :string(1000)
|
|
# chat_channel_id :bigint
|
|
# image_upload_id :bigint
|
|
#
|
|
# Indexes
|
|
#
|
|
# index_discourse_post_event_events_on_image_upload_id (image_upload_id)
|
|
#
|