FEATURE: implements initial support for post events (#24)

This commit is contained in:
Joffrey JAFFEUX 2020-03-26 14:11:33 +01:00 committed by GitHub
parent 0f922cdcb8
commit 988b066ab5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
61 changed files with 3175 additions and 2 deletions

View File

@ -0,0 +1,45 @@
# frozen_string_literal: true
module DiscourseCalendar
class InviteesController < ::ApplicationController
before_action :ensure_logged_in
def index
post_event_invitees = PostEvent.find(params['post-event-id']).invitees
if params[:filter]
post_event_invitees = post_event_invitees.joins(:user).where("users.username LIKE '%#{params[:filter]}%'")
end
render json: ActiveModel::ArraySerializer.new(post_event_invitees.limit(10), each_serializer: InviteeSerializer).as_json
end
def update
invitee = Invitee.find(params[:id])
guardian.ensure_can_act_on_invitee!(invitee)
status = Invitee.statuses[invitee_params[:status].to_sym]
invitee.update_attendance(status: status)
invitee.post_event.publish_update!
render json: InviteeSerializer.new(invitee)
end
def create
status = Invitee.statuses[invitee_params[:status].to_sym]
post_event = PostEvent.find(invitee_params[:post_id])
guardian.ensure_can_act_on_post_event!(post_event)
invitee = Invitee.create!(
status: status,
post_id: invitee_params[:post_id],
user_id: current_user.id,
)
invitee.post_event.publish_update!
render json: InviteeSerializer.new(invitee)
end
private
def invitee_params
params.require(:invitee).permit(:status, :post_id)
end
end
end

View File

@ -0,0 +1,92 @@
# frozen_string_literal: true
module DiscourseCalendar
class PostEventsController < ::ApplicationController
before_action :ensure_logged_in
def index
post_events = PostEvent.visible.where("starts_at > ?", Time.now).limit(10)
render json: ActiveModel::ArraySerializer.new(
post_events,
each_serializer: PostEventSerializer,
scope: guardian).as_json
end
def show
post_event = DiscourseCalendar::PostEvent.find(params[:id])
guardian.ensure_can_see!(post_event.post)
serializer = PostEventSerializer.new(post_event, scope: guardian)
render_json_dump(serializer)
end
def destroy
post_event = DiscourseCalendar::PostEvent.find(params[:id])
guardian.ensure_can_act_on_post_event!(post_event)
post_event.publish_update!
post_event.destroy
render json: success_json
end
def update
DistributedMutex.synchronize("discourse-calendar[post-event-invitee-update]") do
post_event = DiscourseCalendar::PostEvent.find(params[:id])
guardian.ensure_can_edit!(post_event.post)
guardian.ensure_can_act_on_post_event!(post_event)
post_event.enforce_utc!(post_event_params)
case post_event_params[:status].to_i
when PostEvent.statuses[:private]
raw_invitees = Array(post_event_params[:raw_invitees])
post_event.update!(post_event_params.merge(raw_invitees: raw_invitees))
post_event.enforce_raw_invitees!
when PostEvent.statuses[:public]
post_event.update!(post_event_params.merge(raw_invitees: []))
when PostEvent.statuses[:standalone]
post_event.update!(post_event_params.merge(raw_invitees: []))
post_event.invitees.destroy_all
end
post_event.publish_update!
serializer = PostEventSerializer.new(post_event, scope: guardian)
render_json_dump(serializer)
end
end
def create
post_event = DiscourseCalendar::PostEvent.new(post_event_params)
guardian.ensure_can_edit!(post_event.post)
guardian.ensure_can_create_post_event!(post_event)
post_event.enforce_utc!(post_event_params)
case post_event_params[:status].to_i
when PostEvent.statuses[:private]
raw_invitees = Array(post_event_params[:raw_invitees])
post_event.update!(raw_invitees: raw_invitees)
post_event.fill_invitees!
post_event.notify_invitees!
when PostEvent.statuses[:public], PostEvent.statuses[:standalone]
post_event.update!(post_event_params.merge(raw_invitees: []))
end
post_event.publish_update!
serializer = PostEventSerializer.new(post_event, scope: guardian)
render_json_dump(serializer)
end
private
def post_event_params
params
.require(:post_event)
.permit(
:id,
:name,
:starts_at,
:ends_at,
:status,
:display_invitees,
raw_invitees: []
)
end
end
end

View File

@ -0,0 +1,10 @@
# frozen_string_literal: true
module DiscourseCalendar
class UpcomingEventsController < ::ApplicationController
before_action :ensure_logged_in
def index
end
end
end

View File

@ -0,0 +1,22 @@
# frozen_string_literal: true
module DiscourseCalendar
class Invitee < ActiveRecord::Base
self.table_name = 'discourse_calendar_invitees'
belongs_to :post_event, foreign_key: :post_id
belongs_to :user
scope :with_status, ->(status) {
where(status: Invitee.statuses[status])
}
def self.statuses
@statuses ||= Enum.new(going: 0, interested: 1, not_going: 2)
end
def update_attendance(params)
self.update!(params)
end
end
end

View File

@ -0,0 +1,142 @@
# frozen_string_literal: true
module DiscourseCalendar
class PostEvent < ActiveRecord::Base
self.table_name = 'discourse_calendar_post_events'
def self.attributes_protected_by_default
super - ['id']
end
has_many :invitees, foreign_key: :post_id, dependent: :delete_all
belongs_to :post, foreign_key: :id
scope :visible, -> { where(deleted_at: nil) }
validates :name,
length: { in: 5..30 },
unless: -> (post_event) { post_event.name.blank? }
validate :raw_invitees_length
def raw_invitees_length
if self.raw_invitees && self.raw_invitees.length > 10
errors.add(:base, I18n.t("discourse_calendar.post_event.errors.raw_invitees_length", count: 10))
end
end
validate :ends_before_start
def ends_before_start
if self.starts_at && self.ends_at && self.starts_at >= self.ends_at
errors.add(:base, I18n.t("discourse_calendar.post_event.errors.ends_at_before_starts_at"))
end
end
def create_invitees(attrs)
timestamp = Time.now
attrs.map! do |attr|
{
post_id: self.id,
created_at: timestamp,
updated_at: timestamp
}.merge(attr)
end
self.invitees.insert_all!(attrs)
end
def notify_invitees!
self.invitees.where(notified: false).each do |invitee|
invitee.user.notifications.create!(
notification_type: Notification.types[:custom],
topic_id: self.post.topic_id,
post_number: self.post.post_number,
data: {
topic_title: self.post.topic.title,
display_username: self.post.user.username,
message: 'discourse_calendar.invite_user_notification'
}.to_json
)
invitee.update!(notified: true)
end
end
def self.statuses
@statuses ||= Enum.new(standalone: 0, public: 1, private: 2)
end
def self.display_invitees_options
@display_invitees_options ||= Enum.new(everyone: 0, invitees_only: 1, none: 2)
end
def most_likely_going(current_user, limit = SiteSetting.displayed_invitees_limit)
most_likely = []
if self.can_user_update_attendance(current_user)
most_likely << Invitee.find_or_initialize_by(
user_id: current_user.id,
post_id: self.id
)
end
most_likely << Invitee.new(
user_id: self.post.user_id,
status: Invitee.statuses[:going],
post_id: self.id
)
most_likely + self.invitees
.order([:status, :user_id])
.where.not(user_id: current_user.id)
.limit(limit - most_likely.count)
end
def publish_update!
self.post.publish_message!("/post-events/#{self.post.topic_id}", id: self.id)
end
def destroy_extraneous_invitees!
self.invitees.where.not(user_id: fetch_users.select(:id)).delete_all
end
def fill_invitees!
invited_users_ids = fetch_users.pluck(:id) - self.invitees.pluck(:user_id)
if invited_users_ids.present?
self.create_invitees(invited_users_ids.map { |user_id|
{ user_id: user_id }
})
end
end
def fetch_users
@fetched_users ||= User.where(
id: GroupUser.where(
group_id: Group.where(name: self.raw_invitees).select(:id)
).select(:user_id)
).or(User.where(username: self.raw_invitees))
end
def enforce_raw_invitees!
self.destroy_extraneous_invitees!
self.fill_invitees!
self.notify_invitees!
end
def enforce_utc!(params)
if params['starts_at'].present?
params['starts_at'] = Time.parse(params['starts_at']).utc
end
if params['ends_at'].present?
params['ends_at'] = Time.parse(params['ends_at']).utc
end
end
def can_user_update_attendance(user)
self.post.user != user &&
self.status == PostEvent.statuses[:public] ||
(
self.status == PostEvent.statuses[:private] &&
self.invitees.exists?(user_id: user.id)
)
end
end
end

34
app/models/guardian.rb Normal file
View File

@ -0,0 +1,34 @@
# frozen_string_literal: true
class ::Guardian
module CanActOnPostEvent
def can_act_on_post_event?(post_event)
@user.staff? || @user.admin? || @user.id == post_event.post.user_id
end
end
prepend CanActOnPostEvent
module CanActOnInvitee
def can_act_on_invitee?(invitee)
@user.staff? || @user.admin? || @user.id == invitee.user_id
end
end
prepend CanActOnInvitee
module CanCreatePostEvent
def can_create_post_event?(post_event)
@user.staff? || @user.admin?
end
end
prepend CanCreatePostEvent
module CanJoinPostEvent
def can_join_post_event?(post_event)
post_event.status === DiscourseCalendar::PostEvent.statuses[:public] || (
post_event.status === DiscourseCalendar::PostEvent.statuses[:private]
post_event.invitees.find_by(user_id: @user.id)
)
end
end
prepend CanJoinPostEvent
end

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
module DiscourseCalendar
class InviteeSerializer < ApplicationSerializer
attributes :id, :status, :user
def status
object.status ? Invitee.statuses[object.status] : nil
end
def include_id?
object.id
end
def user
BasicUserSerializer.new(object.user, embed: :objects, root: false)
end
end
end

View File

@ -0,0 +1,125 @@
# frozen_string_literal: true
module DiscourseCalendar
class PostEventSerializer < ApplicationSerializer
attributes :id
attributes :creator
attributes :sample_invitees
attributes :watching_invitee
attributes :starts_at
attributes :ends_at
attributes :stats
attributes :status
attributes :raw_invitees
attributes :display_invitees
attributes :post
attributes :should_display_invitees
attributes :name
attributes :can_act_on_post_event
attributes :can_update_attendance
def can_act_on_post_event
scope.can_act_on_post_event?(object)
end
def status
PostEvent.statuses[object.status]
end
# lightweight post object containing
# only needed info for client
def post
{
id: object.post.id,
post_number: object.post.post_number,
url: object.post.url,
topic: {
id: object.post.topic.id,
title: object.post.topic.title
}
}
end
def should_display_invitees
display_invitees?
end
def can_update_attendance
object.can_user_update_attendance(scope.current_user)
end
def display_invitees
PostEvent.display_invitees_options[object.display_invitees]
end
def creator
BasicUserSerializer.new(object.post.user, embed: :objects, root: false)
end
def include_stats?
display_invitees?
end
def stats
counts = object.invitees.group(:status).count
# event creator is always going so we add one
going = (counts[Invitee.statuses[:going]] || 0) + 1
interested = counts[Invitee.statuses[:interested]] || 0
not_going = counts[Invitee.statuses[:not_going]] || 0
unanswered = counts[nil] || 0
{
going: going,
interested: interested,
not_going: not_going,
invited: going + interested + not_going + unanswered
}
end
def watching_invitee
if scope.current_user === object.post.user
watching_invitee = Invitee.new(
user_id: object.post.user.id,
status: Invitee.statuses[:going],
post_id: object.id
)
else
watching_invitee = Invitee.find_by(
user_id: scope.current_user.id,
post_id: object.id
)
end
if watching_invitee
InviteeSerializer.new(watching_invitee, root: false)
end
end
def include_sample_invitees?
display_invitees?
end
def sample_invitees
invitees = object.most_likely_going(scope.current_user)
ActiveModel::ArraySerializer.new(invitees, each_serializer: InviteeSerializer)
end
private
def display_invitees?
object.status != PostEvent.statuses[:standalone] &&
(
object.display_invitees == PostEvent.display_invitees_options[:everyone] ||
(
object.display_invitees == PostEvent.display_invitees_options[:invitees_only] &&
object.invitees.exists?(user_id: scope.current_user.id)
) ||
(
object.display_invitees == PostEvent.display_invitees_options[:none] &&
object.post.user == scope.current_user
)
)
end
end
end

View File

@ -0,0 +1 @@
UPCOMING

View File

@ -0,0 +1 @@
UPCOMING

View File

@ -0,0 +1,11 @@
import RestAdapter from "discourse/adapters/rest";
export default RestAdapter.extend({
basePath() {
return "/discourse-calendar/";
},
pathFor() {
return this._super(...arguments).replace("_", "-");
}
});

View File

@ -0,0 +1,3 @@
import DiscourseCalendarAdapter from "./discourse-calendar-adapter";
export default DiscourseCalendarAdapter.extend();

View File

@ -0,0 +1,3 @@
import DiscourseCalendarAdapter from "./discourse-calendar-adapter";
export default DiscourseCalendarAdapter.extend();

View File

@ -0,0 +1,6 @@
import Component from "@ember/component";
export default Component.extend({
enabled: true,
class: null
});

View File

@ -0,0 +1,105 @@
import ModalFunctionality from "discourse/mixins/modal-functionality";
import Controller from "@ember/controller";
import { action, computed } from "@ember/object";
import { equal } from "@ember/object/computed";
import { extractError } from "discourse/lib/ajax-error";
export default Controller.extend(ModalFunctionality, {
modalTitle: computed("model.isNew", {
get() {
return this.model.isNew ? "create_event_title" : "update_event_title";
}
}),
allowsInvitees: equal("model.status", "private"),
@action
setRawInvitees(_, newInvitees) {
this.set("model.raw_invitees", newInvitees);
},
startsAt: computed("model.starts_at", {
get() {
return this.model.starts_at;
}
}),
endsAt: computed("model.ends_at", {
get() {
return this.model.ends_at;
}
}),
standaloneEvent: equal("model.status", "standalone"),
publicEvent: equal("model.status", "public"),
privateEvent: equal("model.status", "private"),
inviteesOptions: computed("model.status", function() {
const options = [];
if (!this.standaloneEvent) {
options.push({
label: I18n.t("event.display_invitees.everyone"),
value: "everyone"
});
if (this.privateEvent) {
options.push({
label: I18n.t("event.display_invitees.invitees_only"),
value: "invitees_only"
});
}
options.push({
label: I18n.t("event.display_invitees.none"),
value: "none"
});
}
return options;
}),
@action
onChangeDates(changes) {
this.model.setProperties({
starts_at: moment(changes.from)
.utc()
.toISOString(),
ends_at: changes.to
? moment(changes.to)
.utc()
.toISOString()
: null
});
},
@action
destroyPostEvent() {
bootbox.confirm(
I18n.t("event.ui_builder.confirm_delete"),
I18n.t("no_value"),
I18n.t("yes_value"),
confirmed => {
if (confirmed) {
this.model.destroyRecord().then(() => this.send("closeModal"));
}
}
);
},
@action
createEvent() {
this.model
.save()
.then(() => this.send("closeModal"))
.catch(e => this.flash(extractError(e), "error"));
},
@action
updateEvent() {
this.model
.save()
.then(() => this.send("closeModal"))
.catch(e => this.flash(extractError(e), "error"));
}
});

View File

@ -0,0 +1,28 @@
import ModalFunctionality from "discourse/mixins/modal-functionality";
import Controller from "@ember/controller";
import { debounce } from "@ember/runloop";
import { action } from "@ember/object";
export default Controller.extend(ModalFunctionality, {
invitees: null,
filter: null,
isLoading: false,
onShow() {
this._fetchInvitees();
},
@action
onFilterChanged(filter) {
debounce(this, this._fetchInvitees, filter, 250);
},
_fetchInvitees(filter) {
this.set("isLoading", true);
this.store
.findAll("invitee", { "post-event-id": this.model.id, filter })
.then(invitees => this.set("invitees", invitees))
.finally(() => this.set("isLoading", false));
}
});

View File

@ -0,0 +1,9 @@
import Controller from "@ember/controller";
export default Controller.extend({
loadPostEvents(params) {
this.store.findAll("post-event", params).then(postEvents => {
this.set("postEvents", postEvents);
});
}
});

View File

@ -0,0 +1,26 @@
import EmberObject from "@ember/object";
export default EmberObject.extend({
init(params = {}) {
this.title = params.title;
this.startsAt = moment(params.startsAt);
this.endsAt = params.endsAt ? moment(params.endsAt) : null;
},
generateLink() {
const title = encodeURIComponent(this.title);
let dates = [this._formatDate(this.startsAt)];
if (this.endsAt) {
dates.push(this._formatDate(this.endsAt));
dates = `dates=${dates.join("/")}`;
} else {
dates = `date=${dates.join("")}`;
}
return `https://www.google.com/calendar/event?action=TEMPLATE&text=${title}&${dates}`;
},
_formatDate(date) {
return date.toISOString().replace(/-|:|\.\d\d\d/g, "");
}
});

View File

@ -0,0 +1,9 @@
import RestModel from "discourse/models/rest";
export default RestModel.extend({
init() {
this._super(...arguments);
this.__type = "invitee";
}
});

View File

@ -0,0 +1,72 @@
import RestModel from "discourse/models/rest";
// import { ajax } from "discourse/lib/ajax";
// const BASE_URL = "/discourse-calendar/post-events";
const ATTRIBUTES = {
id: {},
name: {},
starts_at: {},
ends_at: {},
raw_invitees: {},
display_invitees: {
transform(value) {
return DISPLAY_INVITEES_OPTIONS[value];
}
},
status: {
transform(value) {
return STATUSES[value];
}
}
};
const DISPLAY_INVITEES_OPTIONS = {
everyone: 0,
invitees_only: 1,
none: 2
};
const STATUSES = {
standalone: 0,
public: 1,
private: 2
};
const PostEvent = RestModel.extend({
init() {
this._super(...arguments);
this.__type = "post-event";
},
updateProperties() {
const attributesKeys = Object.keys(ATTRIBUTES);
return this.getProperties(attributesKeys);
},
createProperties() {
const attributesKeys = Object.keys(ATTRIBUTES);
return this.getProperties(attributesKeys);
},
_transformProps(props) {
const attributesKeys = Object.keys(ATTRIBUTES);
attributesKeys.forEach(key => {
const attribute = ATTRIBUTES[key];
if (attribute.transform) {
props[key] = attribute.transform(props[key]);
}
});
},
beforeUpdate(props) {
this._transformProps(props);
},
beforeCreate(props) {
this._transformProps(props);
}
});
export default PostEvent;

View File

@ -0,0 +1,15 @@
import Route from "@ember/routing/route";
export default Route.extend({
queryParams: {
invited: { refreshModel: true, replace: true }
},
model(params) {
return params;
},
setupController(controller, params) {
controller.loadPostEvents(params);
}
});

View File

@ -0,0 +1,3 @@
import Route from "@ember/routing/route";
export default Route.extend({});

View File

@ -0,0 +1,10 @@
{{#if enabled}}
<div class="event-field {{class}}">
<div class="event-field-label">
<span class="label">{{i18n label}}</span>
</div>
<div class="event-field-control">
{{yield}}
</div>
</div>
{{/if}}

View File

@ -0,0 +1,122 @@
{{#d-modal-body
title=(concat "event.ui_builder." modalTitle)
class="event-ui-builder"
}}
{{#conditional-loading-section isLoading=model.isSaving}}
<form>
{{#event-field class="name" label="event.ui_builder.name.label"}}
{{input
value=(readonly model.name)
placeholderKey="event.ui_builder.name.placeholder"
input=(action (mut model.name) value="target.value")
}}
{{/event-field}}
{{date-time-input-range
from=startsAt
to=endsAt
onChange=(action "onChangeDates")
}}
{{#event-field label="event.ui_builder.status.label"}}
<label class="radio-label">
{{radio-button
name="status"
value="standalone"
selection=model.status
onChange=(action (mut model.status))
}}
<span class="message">
<span class="title">{{i18n "event.post_event_status.standalone.title"}}</span>
<span class="description">{{i18n "event.post_event_status.standalone.description"}}</span>
</span>
</label>
<label class="radio-label">
{{radio-button
name="status"
value="public"
selection=model.status
onChange=(action (mut model.status))
}}
<span class="message">
<span class="title">{{i18n "event.post_event_status.public.title"}}</span>
<span class="description">{{i18n "event.post_event_status.public.description"}}</span>
</span>
</label>
<label class="radio-label">
{{radio-button
name="status"
value="private"
selection=model.status
onChange=(action (mut model.status))
}}
<span class="message">
<span class="title">{{i18n "event.post_event_status.private.title"}}</span>
<span class="description">{{i18n "event.post_event_status.private.description"}}</span>
</span>
</label>
{{/event-field}}
{{#event-field enabled=allowsInvitees label="event.ui_builder.invitees.label"}}
{{user-selector
single=false
onChangeCallback=(action "setRawInvitees")
fullWidthWrap=true
allowAny=false
includeMessageableGroups=true
placeholderKey="composer.users_placeholder"
tabindex="1"
usernames=model.raw_invitees
hasGroups=true
autocomplete="discourse"
excludeCurrentUser=true
}}
{{/event-field}}
{{#if inviteesOptions.length}}
{{#event-field label="event.ui_builder.display_invitees.label"}}
{{#each inviteesOptions as |option|}}
<label class="radio-label">
{{radio-button
name="display_invitees"
value=option.value
selection=model.display_invitees
onChange=(action (mut model.display_invitees))
}}
<span class="message">
{{option.label}}
</span>
</label>
{{/each}}
{{/event-field}}
{{/if}}
</form>
{{/conditional-loading-section}}
{{/d-modal-body}}
<div class="modal-footer">
{{#if model.isNew}}
{{d-button
type="button"
class="btn-primary"
label="event.ui_builder.create"
icon="calendar-day"
action=(action "createEvent")
}}
{{else}}
{{d-button
type="button"
class="btn-primary"
label="event.ui_builder.update"
icon="calendar-day"
action=(action "updateEvent")
}}
{{/if}}
{{d-button
icon="trash-alt"
class="btn-danger"
action="destroyPostEvent"
}}
</div>

View File

@ -0,0 +1,28 @@
{{#d-modal-body
title="event.post-event-invitees-modal.title"
}}
{{input
value=(readonly filter)
input=(action "onFilterChanged" value="target.value")
class="filter"
placeholderKey="event.post-event-invitees-modal.filter_placeholder"
}}
{{#conditional-loading-spinner condition=isLoading}}
<ul class="invitees">
{{#each invitees as |invitee|}}
<li class="invitee">
<span class="user">
{{avatar invitee.user imageSize="medium"}}
{{format-username invitee.user.username}}
</span>
{{#if invitee.status}}
<span class="status {{invitee.status}}">
{{i18n (concat "event.invitee_status." invitee.status)}}
</span>
{{/if}}
</li>
{{/each}}
</ul>
{{/conditional-loading-spinner}}
{{/d-modal-body}}

View File

@ -0,0 +1,31 @@
<table class="table upcoming-events-table">
<thead>
<tr>
<th>id</th>
<th>creator</th>
<th>status</th>
<th>starts at</th>
</tr>
</thead>
<tbody>
{{#each postEvents as |postEvent|}}
<tr>
<td>
<a href={{postEvent.post.url}}>
{{format-post-event-name postEvent}}
</a>
</td>
<td>
{{avatar postEvent.creator imageSize="tiny"}}
{{format-username postEvent.creator.username}}
</td>
<td>
{{postEvent.status}}
</td>
<td>
{{format-future-date postEvent.starts_at}}
</td>
</tr>
{{/each}}
</tbody>
</table>

View File

@ -0,0 +1 @@
{{outlet}}

View File

@ -0,0 +1,5 @@
export default function() {
this.route("upcoming-events", { path: "/upcoming-events" }, function() {
this.route("index", { path: "/" });
});
}

View File

@ -0,0 +1,11 @@
import hbs from "discourse/widgets/hbs-compiler";
import { createWidget } from "discourse/widgets/widget";
export default createWidget("post-event-dates", {
tagName: "section.post-event-dates",
template: hbs`
{{d-icon "clock"}}
<span class="date">{{{attrs.localDates}}}</span>
`
});

View File

@ -0,0 +1,33 @@
import { h } from "virtual-dom";
import { avatarImg } from "discourse/widgets/post";
import { createWidget } from "discourse/widgets/widget";
import { formatUsername } from "discourse/lib/utilities";
export default createWidget("post-event-creator", {
tagName: "span.post-event-creator",
html(attrs) {
const { name, username, avatar_template } = attrs.user;
return h(
"a",
{
attributes: {
class: "topic-invitee-avatar",
"data-user-card": username
}
},
[
avatarImg("tiny", {
template: avatar_template,
username: name || formatUsername(username)
}),
h(
"span",
{ attributes: { class: "username" } },
name || formatUsername(username)
)
]
);
}
});

View File

@ -0,0 +1,59 @@
import { h } from "virtual-dom";
import { avatarImg } from "discourse/widgets/post";
import { createWidget } from "discourse/widgets/widget";
import { formatUsername } from "discourse/lib/utilities";
export default createWidget("post-event-invitee", {
tagName: "li.post-event-invitee",
buildClasses(attrs) {
return [
Ember.isPresent(attrs.invitee.status)
? `status-${attrs.invitee.status}`
: `unanswered`
];
},
html(attrs) {
const { name, username, avatar_template } = attrs.invitee.user;
let statusIcon;
switch (attrs.invitee.status) {
case "going":
statusIcon = "fa-check";
break;
case "interested":
statusIcon = "fa-question";
break;
case "not_going":
statusIcon = "fa-times";
break;
}
const avatarContent = [
avatarImg("large", {
template: avatar_template,
username: name || formatUsername(username)
})
];
if (statusIcon) {
avatarContent.push(
this.attach("avatar-flair", {
primary_group_name: `status-${attrs.invitee.status}`,
primary_group_flair_url: statusIcon
})
);
}
return h(
"a",
{
attributes: {
class: "topic-invitee-avatar",
"data-user-card": username
}
},
avatarContent
);
}
});

View File

@ -0,0 +1,43 @@
import hbs from "discourse/widgets/hbs-compiler";
import { createWidget } from "discourse/widgets/widget";
export default createWidget("post-event-invitees", {
tagName: "section.post-event-invitees",
transform(attrs) {
return {
showAll: attrs.postEvent.stats && attrs.postEvent.stats.invited > 10
};
},
template: hbs`
<div class="header">
<div class="post-event-invitees-status">
<span>{{attrs.postEvent.stats.going}} Going -</span>
<span>{{attrs.postEvent.stats.interested}} Interested -</span>
<span>{{attrs.postEvent.stats.not_going}} Not going -</span>
<span class="invited">on {{attrs.postEvent.stats.invited}} users invited</span>
</div>
{{#if transformed.showAll}}
{{attach
widget="button"
attrs=(hash
className="show-all btn-small"
label="event.post_ui.show_all"
action="showAllInvitees"
actionParam=attrs.postEvent.id
)
}}
{{/if}}
</div>
<ul class="post-event-invitees-avatars">
{{#each attrs.postEvent.sample_invitees as |invitee|}}
{{attach
widget="post-event-invitee"
attrs=(hash invitee=invitee)
}}
{{/each}}
</ul>
`
});

View File

@ -0,0 +1,39 @@
import { h } from "virtual-dom";
import { createWidget } from "discourse/widgets/widget";
export default createWidget("post-event-status", {
tagName: "select.post-event-status",
change(event) {
this.sendWidgetAction("changeWatchingInviteeStatus", event.target.value);
},
buildClasses(attrs) {
if (attrs.watchingInvitee) {
return `status-${attrs.watchingInvitee.status}`;
}
},
html(attrs) {
const statuses = [
{ value: null, name: I18n.t("event.invitee_status.unknown") },
{ value: "going", name: I18n.t("event.invitee_status.going") },
{ value: "interested", name: I18n.t("event.invitee_status.interested") },
{ value: "not_going", name: I18n.t("event.invitee_status.not_going") }
];
const value = attrs.watchingInvitee ? attrs.watchingInvitee.status : null;
return statuses.map(status =>
h(
"option",
{
value: status.value,
class: `status-${status.value}`,
selected: status.value === value
},
status.name
)
);
}
});

View File

@ -0,0 +1,184 @@
import EmberObject from "@ember/object";
import showModal from "discourse/lib/show-modal";
import hbs from "discourse/widgets/hbs-compiler";
import { createWidget } from "discourse/widgets/widget";
import GoogleCalendar from "discourse/plugins/discourse-calendar/discourse/lib/google-calendar";
import { routeAction } from "discourse/helpers/route-action";
import { iconNode } from "discourse-common/lib/icon-library";
export default createWidget("post-event", {
tagName: "div.post-event",
buildKey: attrs => `post-event-${attrs.id}`,
buildAttributes(attrs) {
return { style: `height:${attrs.widgetHeight}px` };
},
buildClasses() {
if (this.state.postEvent) {
return ["has-post-event"];
}
},
showAllInvitees(postId) {
this.store.find("post-event", postId).then(postEvent => {
showModal("post-event-invitees", {
model: postEvent
});
});
},
editPostEvent(postId) {
this.store.find("post-event", postId).then(postEvent => {
showModal("event-ui-builder", {
model: postEvent,
modalClass: "event-ui-builder-modal"
});
});
},
changeWatchingInviteeStatus(status) {
if (this.state.postEvent.watching_invitee) {
this.store.update("invitee", this.state.postEvent.watching_invitee.id, {
status
});
} else {
this.store
.createRecord("invitee")
.save({ post_id: this.state.postEvent.id, status });
}
},
defaultState(attrs) {
return {
postEvent: attrs.postEvent
};
},
sendPMToCreator() {
const router = this.register.lookup("service:router")._router;
routeAction(
"composePrivateMessage",
router,
EmberObject.create(this.state.postEvent.creator),
EmberObject.create(this.state.postEvent.post)
).call();
},
addToGoogleCalendar() {
const link = GoogleCalendar.create({
title: this.state.postEvent.name || this.state.postEvent.post.topic.title,
startsAt: this.state.postEvent.starts_at,
endsAt: this.state.postEvent.ends_at
}).generateLink();
window.open(link, "_blank");
},
transform() {
const postEvent = this.state.postEvent;
let statusIcon = "times";
if (postEvent.status === "private") {
statusIcon = "lock";
}
if (postEvent.status === "public") {
statusIcon = "unlock";
}
return {
postEventStatusLabel: I18n.t(
`event.post_event_status.${postEvent.status}.title`
),
postEventStatusDescription: I18n.t(
`event.post_event_status.${postEvent.status}.description`
),
startsAtMonth: moment(postEvent.starts_at).format("MMM"),
startsAtDay: moment(postEvent.starts_at).format("D"),
postEventName: postEvent.name || postEvent.post.topic.title,
statusClass: `status ${postEvent.status}`,
statusIcon: iconNode(statusIcon)
};
},
template: hbs`
{{#if state.postEvent}}
<header class="post-event-header">
<div class="post-event-date">
<div class="month">{{transformed.startsAtMonth}}</div>
<div class="day">{{transformed.startsAtDay}}</div>
</div>
<div class="post-event-info">
<div class="status-and-name">
<span class={{transformed.statusClass}} title={{transformed.postEventStatusDescription}}>
{{transformed.statusIcon}}
<span>{{transformed.postEventStatusLabel}}</span>
</span>
<span class="name">
{{transformed.postEventName}}
</span>
</div>
<span class="creators">
Created by {{attach widget="post-event-creator" attrs=(hash user=state.postEvent.creator)}}
</span>
</div>
{{#if state.postEvent.can_act_on_post_event}}
<div class="actions">
{{attach
widget="button"
attrs=(hash
className="btn-small"
icon="pencil-alt"
action="editPostEvent"
actionParam=state.postEvent.id
)
}}
</div>
{{/if}}
</header>
{{#if state.postEvent.can_update_attendance}}
<section class="post-event-actions">
{{attach
widget="post-event-status"
attrs=(hash
watchingInvitee=this.state.postEvent.watching_invitee
)
}}
</section>
{{/if}}
<hr />
{{attach widget="post-event-dates" attrs=(hash localDates=attrs.localDates postEvent=state.postEvent)}}
{{#if state.postEvent.should_display_invitees}}
<hr />
{{attach widget="post-event-invitees" attrs=(hash postEvent=state.postEvent)}}
{{/if}}
<footer class="post-event-footer">
{{attach
widget="button"
attrs=(hash
className="btn-small"
icon="calendar-day"
label="event.post_ui.add_to_calendar"
action="addToGoogleCalendar"
)
}}
{{attach
widget="button"
attrs=(hash
className="btn-small"
icon="envelope"
label="event.post_ui.send_pm_to_creator"
action="sendPMToCreator"
)
}}
</footer>
{{/if}}
`
});

View File

@ -0,0 +1,5 @@
import { htmlHelper } from "discourse-common/lib/helpers";
export default htmlHelper(postEvent => {
return moment(postEvent.starts_at).format("LLL");
});

View File

@ -0,0 +1,5 @@
import { htmlHelper } from "discourse-common/lib/helpers";
export default htmlHelper(postEvent => {
return postEvent.name || postEvent.post.topic.title;
});

View File

@ -0,0 +1,56 @@
import { withPluginApi } from "discourse/lib/plugin-api";
import showModal from "discourse/lib/show-modal";
import { Promise } from "rsvp";
function initializeEventUIBuilder(api) {
api.decorateWidget("hamburger-menu:generalLinks", () => {
return {
icon: "calendar-day",
route: "upcoming-events",
label: "upcoming_events.title"
};
});
api.attachWidgetAction("post", "showEventUIBuilder", function(postId) {
return new Promise(resolve => {
if (postId) {
this.store
.find("post-event", postId)
.then(resolve)
.catch(() => {
const postEvent = this.store.createRecord("post-event");
postEvent.setProperties({
id: postId,
status: "public",
display_invitees: "everyone"
});
resolve(postEvent);
});
} else if (this.model) {
resolve(this.model);
}
}).then(model => {
showModal("event-ui-builder", {
model,
modalClass: "event-ui-builder-modal"
});
});
});
api.decorateWidget("post-admin-menu:after", dec => {
return dec.attach("post-admin-menu-button", {
icon: "calendar-day",
label: "event.ui_builder.attach",
action: "showEventUIBuilder",
actionParam: dec.attrs.id
});
});
}
export default {
name: "add-event-ui-builder",
initialize() {
withPluginApi("0.8.7", initializeEventUIBuilder);
}
};

View File

@ -0,0 +1,139 @@
import { cookAsync } from "discourse/lib/text";
import WidgetGlue from "discourse/widgets/glue";
import { getRegister } from "discourse-common/lib/get-owner";
import { withPluginApi } from "discourse/lib/plugin-api";
import { schedule } from "@ember/runloop";
function _decoratePostEvent(api, cooked, post) {
_attachWidget(api, cooked, post);
}
let _glued = [];
function cleanUp() {
_glued.forEach(g => g.cleanUp());
_glued = [];
}
function _attachWidget(api, cooked, postEvent) {
const existing = cooked.querySelector(".post-event");
if (postEvent) {
let widgetHeight = 170;
if (postEvent.should_display_invitees) {
widgetHeight += 125;
}
if (postEvent.can_update_attendance) {
widgetHeight += 65;
}
const postEventContainer = existing || document.createElement("div");
postEventContainer.classList.add("post-event");
postEventContainer.classList.add("is-loading");
postEventContainer.style.height = `${widgetHeight}px`;
postEventContainer.innerHTML = '<div class="spinner medium"></div>';
cooked.prepend(postEventContainer);
const dates = [];
let format;
const startsAt = moment(postEvent.starts_at);
if (
startsAt.hours() > 0 ||
startsAt.minutes() > 0 ||
(postEvent.ends_at &&
(moment(postEvent.ends_at).hours() > 0 ||
moment(postEvent.ends_at).minutes() > 0))
) {
format = "LLL";
} else {
format = "LL";
}
dates.push(
`[date=${moment
.utc(postEvent.starts_at)
.format("YYYY-MM-DD")} time=${moment
.utc(postEvent.starts_at)
.format("HH:mm")} format=${format}]`
);
if (postEvent.ends_at) {
const endsAt = moment.utc(postEvent.ends_at);
dates.push(
`[date=${endsAt.format("YYYY-MM-DD")} time=${endsAt.format(
"HH:mm"
)} format=${format}]`
);
}
cookAsync(dates.join(" → ")).then(result => {
const glue = new WidgetGlue("post-event", getRegister(api), {
postEvent,
widgetHeight,
localDates: $(result.string).html()
});
glue.appendTo(postEventContainer);
_glued.push(glue);
schedule("afterRender", () => {
$(
".discourse-local-date",
$(`[data-post-id="${postEvent.id}"]`)
).applyLocalDates();
});
});
} else {
existing && existing.remove();
}
}
function initializePostEventDecorator(api) {
api.cleanupStream(cleanUp);
api.decorateCooked(($cooked, helper) => {
if (helper) {
const post = helper.getModel();
if (post.post_event) {
_decoratePostEvent(api, $cooked[0], post.post_event);
}
}
});
api.replaceIcon(
"notification.discourse_calendar.invite_user_notification",
"calendar-day"
);
api.modifyClass("controller:topic", {
subscribe() {
this._super(...arguments);
this.messageBus.subscribe("/post-events/" + this.get("model.id"), msg => {
const postNode = document.querySelector(
`.onscreen-post[data-post-id="${msg.id}"] .cooked`
);
if (postNode) {
this.store
.find("post-event", msg.id)
.then(postEvent => _decoratePostEvent(api, postNode, postEvent))
.catch(() => _decoratePostEvent(api, postNode));
}
});
},
unsubscribe() {
this.messageBus.unsubscribe("/post-events/*");
this._super(...arguments);
}
});
}
export default {
name: "post-event-decorator",
initialize() {
withPluginApi("0.8.7", initializePostEventDecorator);
}
};

View File

@ -0,0 +1,354 @@
.event-ui-builder-modal {
.modal-inner-container {
min-width: 50vw;
}
.modal-body {
min-height: 200px;
.d-date-time-input-range {
width: auto;
padding: 0;
border: 0;
}
}
.modal-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.event-field {
display: flex;
margin: 1em 0;
flex-direction: column;
&.name {
input {
width: 100%;
}
}
.event-field-label {
display: flex;
min-height: 1px;
padding-top: 0;
top: 0;
vertical-align: middle;
align-items: center;
.label {
font-weight: 700;
margin-bottom: 0.5em;
}
}
.event-field-control {
display: flex;
flex: 1;
flex-direction: column;
.radio-label {
display: flex;
align-items: center;
margin-bottom: 1em;
&:last-child {
margin-bottom: 0;
}
input[type="radio"] {
width: auto;
margin-right: 0.5em;
}
.message {
margin: 0 0 0 1em;
padding: 0;
display: flex;
flex-direction: column;
.description {
color: $primary-medium;
}
}
}
.ac-wrap {
max-width: 450px;
}
input {
margin: 0;
}
}
}
}
.post-event {
border: 1px solid $primary-low;
display: flex;
&.is-loading {
align-items: center;
justify-content: center;
}
&.has-post-event {
display: flex;
flex-direction: column;
}
.post-event-footer {
padding: 0.5em;
background: $primary-very-low;
margin-top: auto;
}
.post-event-header {
display: flex;
align-items: center;
padding: 1em;
.actions {
margin-bottom: auto;
margin-left: auto;
}
}
.post-event-date {
display: flex;
flex-direction: column;
width: auto;
margin-right: 1em;
.month {
text-align: center;
color: red;
font-size: $font-down-1;
text-transform: uppercase;
}
.day {
text-align: center;
font-weight: 500;
font-size: $font-up-2;
}
}
.post-event-info {
display: flex;
flex-direction: column;
.status-and-name {
display: inline-flex;
align-items: center;
margin-bottom: 0.25em;
.name {
font-weight: 700;
margin-left: 0.25em;
@include ellipsis;
max-width: 45vw;
}
.status {
text-transform: lowercase;
padding: 0.25em 0.5em;
font-size: $font-down-1;
border-radius: 3px;
background-color: $primary-very-low;
color: $primary-medium;
flex-wrap: no-wrap;
display: flex;
align-items: center;
.d-icon {
margin-right: 0.5em;
}
}
}
.creators {
color: $primary-medium;
font-size: $font-down-1;
}
}
.post-event-actions {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1em;
.post-event-status {
&.status-going {
color: $success;
}
&.status-not_going {
color: $danger;
}
}
}
.post-event-creator {
.username {
margin-left: 0.25em;
}
}
.post-event-invitees {
padding: 1em;
overflow-y: auto;
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1em;
.show-all {
margin-left: 0.5em;
text-transform: lowercase;
}
.post-event-invitees-status {
font-weight: 700;
.invited {
font-weight: 500;
color: $primary-medium;
}
}
}
.post-event-invitees-avatars {
padding: 0;
margin: 0;
display: inline-flex;
flex-wrap: wrap;
.post-event-invitee {
list-style: none;
margin-right: 0.5em;
margin-bottom: 0.5em;
&.unanswered {
opacity: 0.25;
}
}
.topic-invitee-avatar {
position: relative;
display: flex;
.avatar-flair {
position: absolute;
right: 0;
bottom: 0;
background: $secondary;
border-radius: 50%;
height: 16px;
width: 16px;
display: flex;
align-items: center;
justify-content: center;
color: $primary-medium;
border: 1px solid $primary-low;
&.avatar-flair-status-going {
color: $success;
}
&.avatar-flair-status-not_going {
color: $danger;
}
.d-icon {
font-size: $font-down-3;
}
}
}
}
}
hr {
margin: 0;
}
.post-event-dates {
display: flex;
align-items: center;
padding: 1em;
.d-icon {
color: $primary-medium;
}
.date {
color: $primary-high;
margin-left: 1em;
.discourse-local-date {
.d-icon {
display: none;
}
}
}
.separator {
color: $primary-high;
margin: 0 0.5em;
text-align: center;
}
}
}
.post-event-invitees-modal {
.filter {
width: 100%;
}
.invitees {
display: flex;
padding: 0;
margin: 0;
flex-direction: column;
.invitee {
list-style: none;
display: flex;
flex: 1;
padding: 0.5em;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid $primary-low;
&:last-child {
border: none;
}
.status {
margin-left: 1em;
&.going {
color: $success;
}
&.not_going {
color: $danger;
}
}
}
}
}
.upcoming-events-table {
width: 100%;
tbody {
tr td {
padding: 0.5em;
}
}
}

View File

@ -1,6 +1,7 @@
en:
js:
discourse_calendar:
invite_user_notification: "%{username} invited you to: %{description}"
on_holiday: "On Holiday"
holiday: "Holiday"
add_to_calendar: "Add to calendar"
@ -8,6 +9,51 @@ en:
title: "Timezone"
instructions: "Your current timezone is %{timezone}."
none: "Select a timezone..."
event:
display_invitees:
everyone: "To everyone"
invitees_only: "To invited users only"
none: "Do not display invited users"
invitee_status:
unknown: "Undecided?"
going: "✓ Going"
not_going: "× Not Going"
interested: "? Interested"
post_event_status:
standalone:
title: Standalone
description: "A standalone event can't be joined."
public:
title: Public
description: "A public event can be joined by anyone."
private:
title: Private
description: "A private event can only be joined by invited users."
post_ui:
show_all: show all
add_to_calendar: add to calendar
send_pm_to_creator: contact
post-event-invitees-modal:
title: "List of invited users"
filter_placeholder: "Filter invited users"
ui_builder:
create_event_title: Create Event
update_event_title: Update Event
confirm_delete: Are you sure you want to delete this event?
create: Create
update: Save
attach: Create event
name:
label: Event name
placeholder: Optional, defaults to topic title
invitees:
label: Invited users/groups
status:
label: Status
display_invitees:
label: Display invited users
upcoming_events:
title: Upcoming events
group_timezones:
search: "Search..."
group_availability: "%{group} availability"

View File

@ -1,6 +1,7 @@
en:
site_settings:
calendar_enabled: "Enable the discourse-calendar plugin. This will add support for a [calendar][/calendar] tag in the first post of a topic."
events_enabled: "Enables users to create events on a topic."
holiday_calendar_topic_id: "Topic ID of staffs holiday / absence calendar."
delete_expired_event_posts_after: "Posts with expired events will be automatically deleted after (n) hours. Set to -1 to disable deletion."
all_day_event_start_time: "Events that do not have a start time specified will start at this time. Format is HH:mm. For 6:00 am, enter 06:00"
@ -13,7 +14,12 @@ en:
working_day_end_hour: "End time of the working day hours."
close_to_working_day_hours_extension: "Set extension time in working day hours to highlight the timezones."
discourse_calendar:
invite_user_notification: "%{username} invited you to: %{description}"
calendar_must_be_in_first_post: "Calendar tag can only be used in first post of a topic."
more_than_one_calendar: "You cant have more than one calendar in a post."
more_than_two_dates: "A post of a calendar topic cant contain more than two dates."
event_expired: "Event expired"
post_event:
errors:
raw_invitees_length: "An event is limited to %{count} users/groups"
ends_at_before_starts_at: "An event can't end before it starts"

View File

@ -2,6 +2,9 @@ plugins:
calendar_enabled:
default: false
client: true
events_enabled:
default: true
client: true
holiday_calendar_topic_id:
default: ""
client: true
@ -40,3 +43,7 @@ plugins:
close_to_working_day_hours_extension:
default: 2
client: true
displayed_invitees_limit:
default: 10
client: false
max: 25

View File

@ -0,0 +1,20 @@
# frozen_string_literal: true
class CreatePostEventsTable < ActiveRecord::Migration[5.2]
def up
create_table :discourse_calendar_post_events, id: false do |t|
t.bigint :id, null: false, primary_key: true
t.integer :status, default: 0, null: false
t.integer :display_invitees, default: 0, null: false
t.datetime :starts_at, null: false, default: -> { 'CURRENT_TIMESTAMP' }
t.datetime :ends_at
t.datetime :deleted_at
t.string :raw_invitees, array: true
t.string :name
end
end
def down
drop_table :discourse_calendar_post_events
end
end

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
class CreateInviteesTable < ActiveRecord::Migration[5.2]
def up
create_table :discourse_calendar_invitees do |t|
t.integer :post_id, null: false
t.integer :user_id, null: false
t.integer :status
t.timestamps null: false
t.boolean :notified, null: false, default: false
end
add_index :discourse_calendar_invitees, [:post_id, :user_id], unique: true
end
def down
drop_table :discourse_calendar_invitees
end
end

357
lib/time_sniffer.rb Normal file
View File

@ -0,0 +1,357 @@
# frozen_string_literal: true
class TimeSniffer
Interval = Struct.new(:from, :to)
Event = Struct.new(:at)
Context = Struct.new(:at, :timezone, :date_order)
class SniffedTime
attr_reader :year
attr_reader :month
attr_reader :day
attr_reader :hours
attr_reader :minutes
attr_reader :seconds
attr_reader :zone
def initialize(year:, month:, day:, hours: 0, minutes: 0, seconds: 0, zone:)
@year = year
@month = month
@day = day
@hours = hours
@minutes = minutes
@seconds = seconds
@zone = zone
end
def self.from_datetime(obj, zone)
new(
year: obj.year,
month: obj.month,
day: obj.day,
hours: obj.hour,
minutes: obj.minute,
seconds: obj.second,
zone: zone
)
end
def to_time
Time.use_zone(self.zone) do
Time.zone.parse("#{self.year}-#{self.month}-#{self.day} #{self.hours}:#{self.minutes}:#{self.seconds}")
end
end
def with(**args)
SniffedTime.new(**to_hash.merge(args))
end
def to_hash
{
year: self.year,
month: self.month,
day: self.day,
hours: self.hours,
minutes: self.minutes,
seconds: self.seconds,
zone: self.zone,
}
end
def ==(other)
return false unless other.kind_of?(SniffedTime)
return false if @year != other.year
return false if @month != other.month
return false if @day != other.day
return false if @hours != other.hours
return false if @minutes != other.minutes
return false if @seconds != other.seconds
return false if @zone != other.zone
true
end
end
class << self
def matchers
@matchers ||= {}
end
def matcher(name, regex, &blk)
matchers[name] = {
regex: regex,
blk: blk,
}
end
end
class Parser
UTC_REGEX = / ?(Z|UTC)/
def initialize(input, context)
@input = input
@context = context
@offset = 0
end
def parse_timezone
m = input_from_offset.match(UTC_REGEX)
if m && m.offset(0)[0] == 0
self.offset += m.offset(0)[1]
"UTC"
end
end
def parse_space
if input[offset] == ' '
self.offset += 1
true
else
false
end
end
def parse_time(relative_to, immediate:)
time, start_offset, stop_offset = peek_time(relative_to)
if time && (!immediate || start_offset == 0)
self.offset += stop_offset
time
end
end
def parse_date
date_match = DATE_REGEX.match(input_from_offset)
if date_match
day, month =
case @context.date_order
when :us
[date_match[2], date_match[1]]
when :sane
[date_match[1], date_match[2]]
end
year = date_match[3]
year =
case year.size
when 2
century = @context.at.year - (@context.at.year % 100)
last_century = century - 100
choices = [
century + year.to_i,
last_century + year.to_i,
]
choices.sort_by { |x|
(@context.at.year - x).abs
}[0]
when 4
year.to_i
end
result =
SniffedTime.new(
year: year,
month: month.to_i,
day: day.to_i,
zone: @context.timezone,
)
self.offset += date_match.offset(0)[1]
result
end
end
def parse_time_with_timezone(relative_to, immediate:)
result = parse_time(relative_to, immediate: immediate)
if result
zone = parse_timezone
if zone
result = result.with(zone: zone)
end
result
end
end
def parse_date_time(relative_to)
date = parse_date
if date
if parse_space
datetime = parse_time_with_timezone(date, immediate: true)
if datetime
[false, datetime]
else
[true, date]
end
else
[true, date]
end
elsif relative_to
datetime = parse_time_with_timezone(relative_to, immediate: false)
if datetime
[false, datetime]
else
[true, nil]
end
end
end
def parse_range
if x = parse_date_time(nil)
from_is_date, from = x
to_is_date, to = parse_date_time(from)
if to
if to_is_date
Interval.new(from.to_time, to.to_time + 1.day)
else
Interval.new(from.to_time, to.to_time)
end
else
if from_is_date
Interval.new(from.to_time, from.to_time + 1.day)
else
Event.new(from.to_time)
end
end
end
end
def input_from_offset
self.input[self.offset..-1]
end
def peek_time(relative_to)
m = self.input_from_offset.match(TIME_REGEX)
if m
parsed =
relative_to.with(
hours: m[1].to_i,
minutes: m[2].to_i,
seconds: 0,
zone: @context.timezone,
)
[parsed, *m.offset(0)]
end
end
attr_reader :input
attr_accessor :offset
end
matcher(:yesterday, /yesterday/) do |m|
today = at.to_date
yesterday = today - 1
Interval.new(
SniffedTime
.from_datetime(yesterday.to_datetime, timezone)
.to_time,
SniffedTime
.from_datetime(today.to_datetime, timezone)
.to_time,
)
end
matcher(:tomorrow, /tomorrow/i) do |_|
tomorrow = at.to_date + 1
the_day_after_tomorrow = tomorrow + 1
Interval.new(
SniffedTime
.from_datetime(tomorrow.to_datetime, timezone)
.to_time,
SniffedTime
.from_datetime(the_day_after_tomorrow.to_datetime, timezone)
.to_time,
)
end
TIME_REGEX = /(\d{1,2}):(\d{2})/
matcher(:time, TIME_REGEX) do |m|
times = input.scan(TIME_REGEX).to_a
from, to = times[0..2]
if to
Interval.new(
SniffedTime.new(
year: at.year,
month: at.month,
day: at.day,
hours: from[0].to_i,
minutes: from[1].to_i,
seconds: 0,
zone: timezone,
).to_time,
SniffedTime.new(
year: at.year,
month: at.month,
day: at.day,
hours: to[0].to_i,
minutes: to[1].to_i,
seconds: 0,
zone: timezone,
).to_time,
)
else
Event.new(
SniffedTime.new(
year: at.year,
month: at.month,
day: at.day,
hours: from[0].to_i,
minutes: from[1].to_i,
seconds: 0,
zone: timezone,
).to_time
)
end
end
DATE_SEPARATOR = /[-\/]/
DATE_REGEX = /(\d{1,2})#{DATE_SEPARATOR}(\d{1,2})#{DATE_SEPARATOR}(\d{2,4})/
matcher(:date, DATE_REGEX) do |m|
Parser.new(input, @context).parse_range
end
def initialize(input, at: DateTime.now, timezone:, date_order:, matchers:, raise_errors: false)
@input = input
@at = at
@timezone = timezone
@date_order = date_order
@context = Context.new(@at, @timezone, @date_order)
@matchers = matchers
@raise_errors = raise_errors
end
def sniff
@matchers.each do |matcher_name|
matcher = self.class.matchers[matcher_name]
regex, blk = matcher.values_at(:regex, :blk)
match = regex.match(@input)
if match
begin
result = instance_exec(match, &blk)
rescue Exception => e
raise if @raise_errors
else
return result if result
end
end
end
nil
end
private
attr_reader :input
attr_reader :at
attr_reader :timezone
attr_reader :date_order
end

121
plugin.rb
View File

@ -3,7 +3,7 @@
# name: discourse-calendar
# about: Display a calendar in the first post of a topic
# version: 0.2
# author: Joffrey Jaffeux
# author: Daniel Waterworth, Joffrey Jaffeux
# url: https://github.com/discourse/discourse-calendar
gem "holidays", "8.0.0", require: false
@ -14,10 +14,15 @@ enabled_site_setting :calendar_enabled
register_asset "stylesheets/vendor/fullcalendar.min.css"
register_asset "stylesheets/common/discourse-calendar.scss"
register_asset "stylesheets/common/post-event.scss"
register_asset "stylesheets/mobile/discourse-calendar.scss", :mobile
register_asset "stylesheets/desktop/discourse-calendar.scss", :desktop
register_svg_icon "fas fa-calendar-day"
register_svg_icon "fas fa-question"
register_svg_icon "fas fa-clock"
after_initialize do
module ::DiscourseCalendar
PLUGIN_NAME ||= "discourse-calendar"
@ -43,18 +48,32 @@ after_initialize do
def self.users_on_holiday=(usernames)
PluginStore.set(PLUGIN_NAME, USERS_ON_HOLIDAY_KEY, usernames)
end
class Engine < ::Rails::Engine
engine_name PLUGIN_NAME
isolate_namespace DiscourseCalendar
end
end
[
"../app/models/calendar_event.rb",
"../app/models/guardian.rb",
"../app/serializers/user_timezone_serializer.rb",
"../app/controllers/discourse_calendar/invitees_controller.rb",
"../app/controllers/discourse_calendar/post_events_controller.rb",
"../app/controllers/discourse_calendar/upcoming_events_controller.rb",
"../app/models/discourse_calendar/post_event.rb",
"../app/models/discourse_calendar/invitee.rb",
"../app/serializers/discourse_calendar/invitee_serializer.rb",
"../app/serializers/discourse_calendar/post_event_serializer.rb",
"../jobs/scheduled/create_holiday_events.rb",
"../jobs/scheduled/destroy_past_events.rb",
"../jobs/scheduled/update_holiday_usernames.rb",
"../lib/calendar_validator.rb",
"../lib/calendar.rb",
"../lib/event_validator.rb",
"../lib/group_timezones.rb"
"../lib/group_timezones.rb",
"../lib/time_sniffer.rb",
].each { |path| load File.expand_path(path, __FILE__) }
register_post_custom_field_type(DiscourseCalendar::CALENDAR_CUSTOM_FIELD, :string)
@ -198,4 +217,102 @@ after_initialize do
add_to_serializer(:site, :include_users_on_holiday?) do
scope.is_staff?
end
require 'post'
class ::Post
has_one :post_event,
dependent: :destroy,
class_name: 'DiscourseCalendar::PostEvent',
foreign_key: :id
end
add_to_serializer(:post, :post_event) do
DiscourseCalendar::PostEventSerializer.new(object.post_event, scope: scope, root: false)
end
reloadable_patch do |plugin|
add_to_serializer(:post, :include_post_event?) do
plugin.enabled?
end
end
Discourse::Application.routes.append do
mount ::DiscourseCalendar::Engine, at: '/'
end
DiscourseCalendar::Engine.routes.draw do
get '/discourse-calendar/post-events/:id' => 'post_events#show'
delete '/discourse-calendar/post-events/:id' => 'post_events#destroy'
get '/discourse-calendar/post-events' => 'post_events#index'
post '/discourse-calendar/post-events' => 'post_events#create'
put '/discourse-calendar/post-events/:id' => 'post_events#update'
put '/discourse-calendar/invitees/:id' => 'invitees#update'
post '/discourse-calendar/invitees' => 'invitees#create'
get '/discourse-calendar/invitees' => 'invitees#index'
get '/upcoming-events' => 'upcoming_events#index'
end
DiscourseEvent.on(:post_destroyed) do |post|
if post.post_event
post.post_event.update!(deleted_at: Time.now)
end
end
DiscourseEvent.on(:post_recovered) do |post|
if post.post_event
post.post_event.update!(deleted_at: nil)
end
end
DiscourseEvent.on(:post_edited) do |post, topic_changed|
if post.post_event && post.is_first_post? && post.topic && topic_changed && post.topic != Archetype.private_message
time_range = extract_time_range(post.topic, post.user)
case time_range
when TimeSniffer::Interval
post.post_event.update!(
starts_at: time_range.from.to_time.utc,
ends_at: time_range.to.to_time.utc,
)
when TimeSniffer::Event
post.post_event.update!(
starts_at: time_range.at.to_time.utc
)
end
post.post_event.publish_update!
end
end
def extract_time_range(topic, user)
TimeSniffer.new(
topic.title,
at: topic.created_at,
timezone: user.user_option.timezone || 'UTC',
date_order: :sane,
matchers: [:tomorrow, :date, :time],
).sniff
end
DiscourseEvent.on(:topic_created) do |topic, args, user|
if topic.archetype != Archetype.private_message
time_range = extract_time_range(topic, user)
case time_range
when TimeSniffer::Interval
DiscourseCalendar::PostEvent.create!(
id: topic.first_post.id,
starts_at: time_range.from.to_time.utc,
ends_at: time_range.to.to_time.utc,
status: DiscourseCalendar::PostEvent.statuses[:standalone]
)
when TimeSniffer::Event
DiscourseCalendar::PostEvent.create!(
id: topic.first_post.id,
starts_at: time_range.at.to_time.utc,
status: DiscourseCalendar::PostEvent.statuses[:standalone]
)
end
end
end
end

View File

@ -0,0 +1,43 @@
# frozen_string_literal: true
require "rails_helper"
require_relative '../fabricators/post_event_fabricator'
describe Post do
PostEvent ||= DiscourseCalendar::PostEvent
fab!(:user) { Fabricate(:user) }
fab!(:topic) { Fabricate(:topic, user: user) }
fab!(:post1) { Fabricate(:post, topic: topic) }
fab!(:post_event) { Fabricate(:post_event, post: post1) }
before do
freeze_time
SiteSetting.queue_jobs = false
end
context 'when a post with an event is destroyed' do
it 'sets deleted_at on the post_event' do
expect(post_event.deleted_at).to be_nil
PostDestroyer.new(user, post_event.post).destroy
post_event.reload
expect(post_event.deleted_at).to eq(Time.now)
end
end
context 'when a post with an event is recovered' do
it 'nullifies deleted_at on the post_event' do
PostDestroyer.new(user, post_event.post).destroy
post_event.reload
expect(post_event.deleted_at).to eq(Time.now)
PostDestroyer.new(user, post_event.post).recover
post_event.reload
expect(post_event.deleted_at).to be_nil
end
end
end

View File

@ -0,0 +1,31 @@
# frozen_string_literal: true
require "rails_helper"
describe Topic do
PostEvent ||= DiscourseCalendar::PostEvent
before do
freeze_time
SiteSetting.queue_jobs = false
end
fab!(:user) { Fabricate(:user) }
context 'when a topic is created' do
context 'with a date' do
it 'creates a post event' do
post_with_date = PostCreator.create!(
user,
title: 'Lets buy a boat with me tomorrow',
raw: 'The boat market is quite active lately.'
)
post_event = PostEvent.find(post_with_date.id)
expect(post_event).to be_present
expect(post_event.starts_at).to eq(post_with_date.topic.created_at.tomorrow.beginning_of_day)
expect(post_event.status).to eq(PostEvent.statuses[:standalone])
end
end
end
end

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
Fabricator(:post_event, from: 'DiscourseCalendar::PostEvent') do
post { |attrs| attrs[:post] }
id { |attrs| attrs[:post].id }
status { |attrs|
attrs[:status] ?
DiscourseCalendar::PostEvent.statuses[attrs[:status]] :
DiscourseCalendar::PostEvent.statuses[:public]
}
starts_at { |attrs| attrs[:starts_at] || 1.day.from_now.iso8601 }
ends_at { |attrs| attrs[:ends_at] }
end

View File

@ -0,0 +1,211 @@
# frozen_string_literal: true
require "rails_helper"
describe TimeSniffer do
before do
freeze_time DateTime.parse('2020-04-24 14:10')
end
let(:default_context) {
{
at: DateTime.parse('2020-1-20 00:00:00'),
timezone: 'EST',
date_order: :sane,
matchers: [:tomorrow, :date, :time],
raise_errors: true,
}
}
define_method(:expect_parsed_as_interval) do |str, from:, to:, context: default_context|
Time.use_zone(context[:timezone]) do
expect(TimeSniffer.new(str, **context).sniff).to(
eq(TimeSniffer::Interval.new(Time.zone.parse(from), Time.zone.parse(to)))
)
end
end
define_method(:expect_parsed_as_event) do |str, at, context: default_context|
Time.use_zone(context[:timezone]) do
expect(TimeSniffer.new(str, **context).sniff).to(
eq(TimeSniffer::Event.new(Time.zone.parse(at)))
)
end
end
define_method(:expect_parsed_as_nil) do |str, context: default_context|
expect(TimeSniffer.new(str, **context).sniff).to(
eq(nil)
)
end
it "should support tomorrow with a timezone" do
expect_parsed_as_interval(
"tomorrow",
from: "2020-1-21 EST",
to: "2020-1-22 EST",
)
end
it "should support Tomorrow" do
expect_parsed_as_interval(
"Tomorrow",
from: "2020-1-21",
to: "2020-1-22",
)
end
it "should support 14:00" do
expect_parsed_as_event("14:00", "2020-1-20 14:00 EST")
end
it "should support 14:24" do
expect_parsed_as_event("14:24", "2020-1-20 14:24 EST")
end
it "should support 15:00 with emojis" do
expect_parsed_as_event("😊😊😊😊15:00😊😊😊😊", "2020-1-20 15:00 EST")
end
it "should support 14:00 - 15:00" do
expect_parsed_as_interval(
"14:00 - 15:00",
from: "2020-1-20 14:00 EST",
to: "2020-1-20 15:00 EST",
)
end
it "should support too many times" do
expect_parsed_as_interval(
"14:00 - 15:00 asotuhosthu 16:00",
from: "2020-1-20 14:00 EST",
to: "2020-1-20 15:00 EST",
)
end
it "should support too many times" do
expect_parsed_as_interval(
"14:00 - 15:00 asotuhosthu 16:00",
from: "2020-1-20 14:00 EST",
to: "2020-1-20 15:00 EST",
)
end
it "should support a date" do
expect_parsed_as_interval(
"31/3/25",
from: "2025-3-31 00:00 EST",
to: "2025-4-1 00:00 EST",
)
end
it "should support a date in the past century" do
expect_parsed_as_interval(
"31/3/75",
from: "1975-3-31 00:00 EST",
to: "1975-4-1 00:00 EST",
)
end
it "should support a date with a year with 4 digits" do
expect_parsed_as_interval(
"31/3/2021",
from: "2021-3-31 00:00 EST",
to: "2021-4-1 00:00 EST",
)
end
it "should support a date with hyphens" do
expect_parsed_as_interval(
"31-3-25",
from: "2025-3-31 00:00 EST",
to: "2025-4-1 00:00 EST",
)
end
it "should support a date with a time" do
expect_parsed_as_event(
"31-3-25 08:00",
"2025-3-31 08:00 EST",
)
end
it "should support a date with a time with non-zero minutes" do
expect_parsed_as_event(
"31-3-25 08:45",
"2025-3-31 08:45 EST",
)
end
it "should support a date with a time and a timezone" do
expect_parsed_as_event(
"31-3-25 08:00 UTC",
"2025-3-31 08:00:00 UTC",
context: default_context.merge(timezone: 'EST'),
)
end
it "should support a date with a time and a timezone" do
expect_parsed_as_event(
"31-3-25 08:00UTC",
"2025-3-31 08:00:00 UTC",
context: default_context.merge(timezone: 'EST'),
)
end
it "should support a date with a time and a timezone" do
expect_parsed_as_event(
"31-3-25 08:00Z",
"2025-3-31 08:00:00 UTC",
context: default_context.merge(timezone: 'EST'),
)
end
it "should support a date range" do
expect_parsed_as_interval(
"25/2/21 - 10/3/22",
from: "2021-2-25 00:00 EST",
to: "2022-3-11 00:00 EST",
)
end
it "should support a date range" do
expect_parsed_as_interval(
"25/2/21 - 10/3/22 14:00",
from: "2021-2-25 00:00 EST",
to: "2022-3-10 14:00 EST",
)
end
it "should support a date range with two times" do
expect_parsed_as_interval(
"25/2/21 9:00 - 10/3/22 14:00",
from: "2021-2-25 09:00 EST",
to: "2022-3-10 14:00 EST",
)
end
it "should support a date range with two times where the second is relative to the first" do
expect_parsed_as_interval(
"25/2/21 9:00 - 14:00",
from: "2021-2-25 09:00 EST",
to: "2021-2-25 14:00 EST",
)
end
it "should correctly handle timezones in future" do
expect_parsed_as_event(
"24/06/2020 14:23",
"2020-06-24 14:23 CEST",
context: default_context.merge(timezone: 'Europe/Paris'),
)
end
it "should not find a time in a random number" do
expect_parsed_as_nil("1500")
end
it "should not find a time in random numbers and an emoji" do
expect_parsed_as_nil("15😊00")
end
end

View File

@ -0,0 +1,67 @@
# frozen_string_literal: true
require 'rails_helper'
require_relative '../fabricators/post_event_fabricator'
module DiscourseCalendar
describe InviteesController do
fab!(:user) { Fabricate(:user, admin: true) }
fab!(:topic) { Fabricate(:topic, user: user) }
fab!(:post1) { Fabricate(:post, user: user, topic: topic) }
before do
SiteSetting.queue_jobs = false
sign_in(user)
end
context 'when a post event exists' do
context 'when an invitee exists' do
fab!(:invitee1) { Fabricate(:user) }
fab!(:post_event) {
pe = Fabricate(:post_event, post: post1)
pe.create_invitees([{
user_id: invitee1.id,
status: Invitee.statuses[:going]
}])
pe
}
it 'updates its status' do
invitee = post_event.invitees.first
expect(invitee.status).to eq(0)
put "/discourse-calendar/invitees/#{invitee.id}.json", params: {
invitee: {
status: "interested"
}
}
invitee.reload
expect(invitee.status).to eq(1)
end
end
context 'when an invitee doesnt exist' do
fab!(:post_event) { Fabricate(:post_event, post: post1) }
it 'creates an invitee' do
post "/discourse-calendar/invitees.json", params: {
invitee: {
user_id: user.id,
post_id: post_event.id,
status: "not_going",
}
}
expect(Invitee).to exist(
post_id: post_event.id,
user_id: user.id,
status: 2,
)
end
end
end
end
end

View File

@ -0,0 +1,328 @@
# frozen_string_literal: true
require "rails_helper"
require_relative '../fabricators/post_event_fabricator'
module DiscourseCalendar
describe PostEventsController do
fab!(:user) { Fabricate(:user, admin: true) }
fab!(:topic) { Fabricate(:topic, user: user) }
fab!(:post1) { Fabricate(:post, user: user, topic: topic) }
fab!(:invitee1) { Fabricate(:user) }
fab!(:invitee2) { Fabricate(:user) }
before do
SiteSetting.queue_jobs = false
SiteSetting.displayed_invitees_limit = 3
end
context 'when a post exists' do
fab!(:invitee3) { Fabricate(:user) }
fab!(:invitee4) { Fabricate(:user) }
fab!(:invitee5) { Fabricate(:user) }
fab!(:group) {
Fabricate(:group).tap do |g|
g.add(invitee2)
g.add(invitee3)
g.save!
end
}
before do
sign_in(user)
end
it 'creates a post event' do
post '/discourse-calendar/post-events.json', params: {
post_event: {
id: post1.id
}
}
expect(response.status).to eq(200)
json = ::JSON.parse(response.body)
expect(json['post_event']['id']).to eq(post1.id)
expect(PostEvent).to exist(id: post1.id)
end
it 'accepts user and group invitees' do
invitees = [invitee1.username, group.name]
post '/discourse-calendar/post-events.json', params: {
post_event: {
id: post1.id,
raw_invitees: invitees,
status: PostEvent.statuses[:private],
display_invitees: PostEvent.display_invitees_options[:everyone]
}
}
expect(response.status).to eq(200)
json = ::JSON.parse(response.body)
sample_invitees = json['post_event']['sample_invitees']
expect(sample_invitees.map { |i| i['user']['id'] }).to match_array([user.id, invitee1.id, group.group_users.first.user.id])
raw_invitees = json['post_event']['raw_invitees']
expect(raw_invitees).to match_array(invitees)
end
it 'accepts one user invitee' do
post '/discourse-calendar/post-events.json', params: {
post_event: {
id: post1.id,
status: PostEvent.statuses[:private],
raw_invitees: [invitee1.username],
}
}
expect(response.status).to eq(200)
json = ::JSON.parse(response.body)
sample_invitees = json['post_event']['sample_invitees']
expect(sample_invitees[0]['user']['username']).to eq(user.username)
expect(sample_invitees[1]['user']['username']).to eq(invitee1.username)
end
it 'accepts one group invitee' do
post '/discourse-calendar/post-events.json', params: {
post_event: {
id: post1.id,
status: PostEvent.statuses[:private],
raw_invitees: [group.name],
}
}
expect(response.status).to eq(200)
json = ::JSON.parse(response.body)
sample_invitees = json['post_event']['sample_invitees']
expect(sample_invitees.map { |i| i['user']['username'] }).to match_array([user.username] + group.group_users.map(&:user).map(&:username))
end
it 'accepts no invitee' do
post '/discourse-calendar/post-events.json', params: {
post_event: {
id: post1.id,
raw_invitees: [],
status: PostEvent.statuses[:private],
display_invitees: PostEvent.display_invitees_options[:everyone]
}
}
expect(response.status).to eq(200)
json = ::JSON.parse(response.body)
sample_invitees = json['post_event']['sample_invitees']
expect(sample_invitees.count).to eq(1)
expect(sample_invitees[0]['user']['username']).to eq(user.username)
end
it 'limits displayed invitees' do
post '/discourse-calendar/post-events.json', params: {
post_event: {
id: post1.id,
status: PostEvent.statuses[:private],
raw_invitees: [
invitee1.username,
invitee2.username,
invitee3.username,
invitee4.username,
invitee5.username,
],
}
}
expect(response.status).to eq(200)
json = ::JSON.parse(response.body)
sample_invitees = json['post_event']['sample_invitees']
expect(sample_invitees.map { |i| i['user']['username'] }).to match_array([user.username, invitee1.username, invitee2.username])
end
context 'when a post_event exists' do
fab!(:post_event) { Fabricate(:post_event, post: post1) }
context 'when we update the post_event' do
context 'when status changes from standalone to private' do
it 'changes the status, raw_invitees and invitees' do
post_event.update!(status: PostEvent.statuses[:standalone])
put "/discourse-calendar/post-events/#{post_event.id}.json", params: {
post_event: {
status: PostEvent.statuses[:private].to_s,
raw_invitees: [invitee1.username]
}
}
post_event.reload
expect(post_event.status).to eq(PostEvent.statuses[:private])
expect(post_event.raw_invitees).to eq([invitee1.username])
expect(post_event.invitees.pluck(:user_id)).to match_array([invitee1.id])
end
end
context 'when status changes from standalone to public' do
it 'changes the status' do
post_event.update!(status: PostEvent.statuses[:standalone])
put "/discourse-calendar/post-events/#{post_event.id}.json", params: {
post_event: {
status: PostEvent.statuses[:public].to_s
}
}
post_event.reload
expect(post_event.status).to eq(PostEvent.statuses[:public])
expect(post_event.raw_invitees).to eq([])
expect(post_event.invitees).to eq([])
end
end
context 'when status changes from private to standalone' do
it 'changes the status' do
post_event.update!(
status: PostEvent.statuses[:private],
raw_invitees: [invitee1.username]
)
post_event.fill_invitees!
post_event.reload
expect(post_event.invitees.pluck(:user_id)).to eq([invitee1.id])
expect(post_event.raw_invitees).to eq([invitee1.username])
put "/discourse-calendar/post-events/#{post_event.id}.json", params: {
post_event: {
status: PostEvent.statuses[:standalone].to_s
}
}
post_event.reload
expect(post_event.status).to eq(PostEvent.statuses[:standalone])
expect(post_event.raw_invitees).to eq([])
expect(post_event.invitees).to eq([])
end
end
context 'when status changes from private to public' do
it 'changes the status, removes raw_invitees and keeps invitees' do
post_event.update!(
status: PostEvent.statuses[:private],
raw_invitees: [invitee1.username]
)
post_event.fill_invitees!
post_event.reload
expect(post_event.invitees.pluck(:user_id)).to eq([invitee1.id])
expect(post_event.raw_invitees).to eq([invitee1.username])
put "/discourse-calendar/post-events/#{post_event.id}.json", params: {
post_event: {
status: PostEvent.statuses[:public].to_s
}
}
post_event.reload
expect(post_event.status).to eq(PostEvent.statuses[:public])
expect(post_event.raw_invitees).to eq([])
expect(post_event.invitees.pluck(:user_id)).to eq([invitee1.id])
end
end
context 'when status changes from public to private' do
it 'changes the status, removes raw_invitees and keeps invitees' do
post_event.update!(status: PostEvent.statuses[:public])
post_event.create_invitees([
{ user_id: invitee1.id },
{ user_id: invitee2.id },
])
post_event.reload
expect(post_event.invitees.pluck(:user_id)).to match_array([invitee1.id, invitee2.id])
expect(post_event.raw_invitees).to eq(nil)
put "/discourse-calendar/post-events/#{post_event.id}.json", params: {
post_event: {
status: PostEvent.statuses[:private].to_s,
raw_invitees: [invitee1.username]
}
}
post_event.reload
expect(post_event.status).to eq(PostEvent.statuses[:private])
expect(post_event.raw_invitees).to eq([invitee1.username])
expect(post_event.invitees.pluck(:user_id)).to eq([invitee1.id])
end
end
context 'when status changes from public to standalone' do
it 'changes the status, removes invitees' do
post_event.update!(
status: PostEvent.statuses[:public]
)
post_event.create_invitees([ { user_id: invitee1.id } ])
post_event.reload
expect(post_event.invitees.pluck(:user_id)).to eq([invitee1.id])
expect(post_event.raw_invitees).to eq(nil)
put "/discourse-calendar/post-events/#{post_event.id}.json", params: {
post_event: {
status: PostEvent.statuses[:standalone].to_s
}
}
post_event.reload
expect(post_event.status).to eq(PostEvent.statuses[:standalone])
expect(post_event.raw_invitees).to eq([])
expect(post_event.invitees).to eq([])
end
end
end
context 'acting user has created the post_event' do
it 'destroys a post_event' do
expect(post_event.persisted?).to be(true)
messages = MessageBus.track_publish do
delete "/discourse-calendar/post-events/#{post_event.id}.json"
end
expect(messages.count).to eq(1)
message = messages.first
expect(message.channel).to eq("/post-events/#{post_event.post.topic_id}")
expect(message.data[:id]).to eq(post_event.id)
expect(response.status).to eq(200)
expect(PostEvent).to_not exist(id: post_event.id)
end
end
context 'acting user has not created the post_event' do
fab!(:lurker) { Fabricate(:user) }
before do
sign_in(lurker)
end
it 'doesnt destroy the post_event' do
expect(post_event.persisted?).to be(true)
delete "/discourse-calendar/post-events/#{post_event.id}.json"
expect(response.status).to eq(403)
expect(PostEvent).to exist(id: post_event.id)
end
it 'doesnt update the post_event' do
put "/discourse-calendar/post-events/#{post_event.id}.json", params: {
post_event: {
status: PostEvent.statuses[:public],
}
}
expect(response.status).to eq(403)
end
end
end
end
end
end