FEATURE: when user is on holiday set user status in core (#333)

Before, the calendar plugin was setting this custom holiday flair in many places in Discourse. Starting from this PR, the calendar plugin will be setting user status in core instead.

Note that from now we start to show holiday's end date. If a user has several holiday events simultaneously (this can happen easily, for example, if a user is on vacation and today is a public holiday in their country), the date will be taken from the longest event.

Note also that there is an edge case that's not handled in this PR - if a user has several holidays one after another (let's say they are sick this week and has a vacation on the next week), it would be nice to show as the end date of the holiday the ending date of the second holiday. For now, the plugin uses the end date of the current holiday in such cases, but I'll improve it in one of the next PRs.

Also, in this PR, I directly use methods of the user model from Core. We definitely need API calls instead. This fix is also coming soon.
This commit is contained in:
Andrei Prigorshnev 2022-10-24 16:57:53 +04:00 committed by GitHub
parent ae9f261ded
commit ccb4993e57
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 316 additions and 195 deletions

View File

@ -5,6 +5,17 @@ class CalendarEvent < ActiveRecord::Base
belongs_to :post
belongs_to :user
after_destroy :clear_holiday_user_status
def ends_at
end_date || (start_date + 24.hours)
end
def underway?
now = Time.zone.now
start_date < now && now < ends_at
end
def self.update(post)
CalendarEvent.where(post_id: post.id).destroy_all
@ -54,6 +65,14 @@ class CalendarEvent < ActiveRecord::Base
private
def clear_holiday_user_status
return if user.blank? || user.user_status.blank?
if DiscourseCalendar::HolidayStatus.is_holiday_status?(user.user_status)
user.clear_status!
end
end
def self.convert_to_date_time(value)
return if value.blank?

View File

@ -1,96 +0,0 @@
import I18n from "I18n";
import { withPluginApi } from "discourse/lib/plugin-api";
import { cancel } from "@ember/runloop";
import getURL from "discourse-common/lib/get-url";
import { emojiUnescape } from "discourse/lib/text";
import discourseLater from "discourse-common/lib/later";
function applyFlairOnMention(element, username) {
if (!element) {
return;
}
const href = getURL(`/u/${username.toLowerCase()}`);
const mentions = element.querySelectorAll(`a.mention[href="${href}"]`);
mentions.forEach((mention) => {
if (!mention.querySelector(".on-holiday")) {
mention.insertAdjacentHTML(
"beforeend",
emojiUnescape(":desert_island:", { class: "on-holiday" })
);
}
mention.classList.add("on-holiday");
});
}
export default {
name: "add-holiday-flair",
initialize() {
withPluginApi("0.10.1", (api) => {
const usernames = api.container.lookup("service:site").users_on_holiday;
if (usernames && usernames.length > 0) {
api.addUsernameSelectorDecorator((username) => {
if (usernames.includes(username)) {
return `<span class="on-holiday">${emojiUnescape(
":desert_island:",
{ class: "on-holiday" }
)}</span>`;
}
});
}
});
withPluginApi("0.8", (api) => {
const usernames = api.container.lookup("service:site").users_on_holiday;
if (usernames?.length > 0) {
let flairHandler;
api.cleanupStream(() => cancel(flairHandler));
if (api.decorateChatMessage) {
api.decorateChatMessage((message) => {
usernames.forEach((username) =>
applyFlairOnMention(message, username)
);
});
}
api.decorateCookedElement(
(element, helper) => {
if (helper) {
// decorating a post
usernames.forEach((username) =>
applyFlairOnMention(element, username)
);
} else {
// decorating preview
cancel(flairHandler);
flairHandler = discourseLater(
() =>
usernames.forEach((username) =>
applyFlairOnMention(element, username)
),
1000
);
}
},
{ id: "discourse-calendar-holiday-flair" }
);
api.addPosterIcon((cfs) => {
if (cfs.on_holiday) {
return {
emoji: "desert_island",
className: "holiday",
title: I18n.t("discourse_calendar.on_holiday"),
};
}
});
}
});
},
};

View File

@ -57,6 +57,8 @@ en:
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"
holiday_status:
description: "On holiday"
discourse_post_event:
notifications:
before_event_reminder: "%{title} is about to start."

View File

@ -8,26 +8,21 @@ module Jobs
return unless SiteSetting.calendar_enabled
return unless topic_id = SiteSetting.holiday_calendar_topic_id.presence
user_ids = []
usernames = []
events = CalendarEvent.where(topic_id: topic_id)
users_on_holiday = DiscourseCalendar::UsersOnHoliday.from(events)
CalendarEvent.where(topic_id: topic_id).each do |event|
next if event.user_id.blank? || event.username.blank?
end_date = event.end_date ? event.end_date : event.start_date + 24.hours
if event.start_date < Time.zone.now && Time.zone.now < end_date
user_ids << event.user_id
usernames << event.username
end
end
DiscourseCalendar.users_on_holiday = users_on_holiday.values.map { |u| u[:username] }
synchronize_user_custom_fields(users_on_holiday)
set_holiday_statuses(users_on_holiday)
end
user_ids.uniq!
usernames.uniq!
DiscourseCalendar.users_on_holiday = usernames
private
def synchronize_user_custom_fields(users_on_holiday)
custom_field_name = DiscourseCalendar::HOLIDAY_CUSTOM_FIELD
if user_ids.present?
if users_on_holiday.present?
user_ids = users_on_holiday.keys
values = user_ids.map { |id| "(#{id}, '#{custom_field_name}', 't', now(), now())" }
DB.exec <<~SQL, custom_field_name
@ -38,12 +33,34 @@ module Jobs
DB.exec <<~SQL, custom_field_name, user_ids
DELETE FROM user_custom_fields
WHERE name = ?
AND user_id NOT IN (?)
WHERE name = ?
AND user_id NOT IN (?)
SQL
else
DB.exec("DELETE FROM user_custom_fields WHERE name = ?", custom_field_name)
end
end
def set_holiday_statuses(users_on_holiday)
return if !SiteSetting.enable_user_status
User
.where(id: users_on_holiday.keys)
.includes(:user_status)
.each { |u| set_holiday_status(u, users_on_holiday[u.id][:ends_at]) }
end
def set_holiday_status(user, ends_at)
status = user.user_status
if status.blank? ||
(DiscourseCalendar::HolidayStatus.is_holiday_status?(status) && status.ends_at != ends_at)
user.set_status!(
I18n.t("discourse_calendar.holiday_status.description"),
DiscourseCalendar::HolidayStatus::EMOJI,
ends_at)
end
end
end
end

11
lib/holiday_status.rb Normal file
View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
module DiscourseCalendar
class HolidayStatus
EMOJI = 'desert_island'
def self.is_holiday_status?(status)
status.emoji == EMOJI && status.description == I18n.t("discourse_calendar.holiday_status.description")
end
end
end

21
lib/users_on_holiday.rb Normal file
View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
module DiscourseCalendar
class UsersOnHoliday
def self.from(calendar_events)
calendar_events
.filter { |e| e.user_id.present? && e.username.present? }
.filter { |e| e.underway? }
.group_by(&:user_id)
.map { |_, events| events.sort_by(&:ends_at).last }
.to_h { |e| [
e.user_id,
{
username: e.username,
ends_at: e.ends_at
}
]
}
end
end
end

View File

@ -345,6 +345,8 @@ after_initialize do
../lib/event_validator.rb
../lib/group_timezones.rb
../lib/time_sniffer.rb
../lib/users_on_holiday.rb
../lib/holiday_status.rb
].each { |path| load File.expand_path(path, __FILE__) }
register_post_custom_field_type(

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
Fabricator(:calendar_event) do
user
username { |attrs| attrs[:user].username }
topic { |attrs| Fabricate(:topic, user: attrs[:user]) }
post { |attrs| Fabricate(:post, user: attrs[:user], topic: attrs[:topic]) }
start_date "2000-01-01"
end

View File

@ -571,6 +571,68 @@ describe Post do
end
end
context "with holiday events" do
let(:calendar_post) { create_post(raw: "[calendar]\n[/calendar]") }
before do
SiteSetting.holiday_calendar_topic_id = calendar_post.topic_id
end
context "when deleting a post with an event" do
it "clears user status that was previously set by the calendar plugin" do
SiteSetting.enable_user_status = true
raw = 'Vacation [date="2018-06-05" time="10:20:00"] to [date="2018-06-06" time="10:20:00"]'
post = create_post(raw: raw, topic: calendar_post.topic)
freeze_time Time.utc(2018, 6, 5, 10, 30)
DiscourseCalendar::UpdateHolidayUsernames.new.execute(nil)
# the job has set the holiday status:
status = post.user.user_status
expect(status).to be_present
expect(status.description).to eq(I18n.t("discourse_calendar.holiday_status.description"))
expect(status.emoji).to eq(DiscourseCalendar::HolidayStatus::EMOJI)
expect(status.ends_at).to eq_time(Time.utc(2018, 6, 6, 10, 20))
# after destroying the post the holiday status disappears:
PostDestroyer.new(user, post).destroy
post.user.reload
expect(post.user.user_status).to be_nil
end
it "doesn't clear user status that wasn't set by the calendar plugin" do
SiteSetting.enable_user_status = true
raw = 'Vacation [date="2018-06-05" time="10:20:00"] to [date="2018-06-06" time="10:20:00"]'
post = create_post(raw: raw, topic: calendar_post.topic)
freeze_time Time.utc(2018, 6, 5, 10, 30)
DiscourseCalendar::UpdateHolidayUsernames.new.execute(nil)
# the job has set the holiday status:
status = post.user.user_status
expect(status).to be_present
expect(status.description).to eq(I18n.t("discourse_calendar.holiday_status.description"))
expect(status.emoji).to eq(DiscourseCalendar::HolidayStatus::EMOJI)
expect(status.ends_at).to eq_time(Time.utc(2018, 6, 6, 10, 20))
# user set their own status
custom_status = {
description: "I am working on holiday",
emoji: "construction_worker_man"
}
post.user.set_status!(custom_status[:description], custom_status[:emoji])
# the status that was set by user doesn't disappear after destroying the post:
PostDestroyer.new(user, post).destroy
post.user.reload
status = post.user.user_status
expect(status).to be_present
expect(status.description).to eq(custom_status[:description])
expect(status.emoji).to eq(custom_status[:emoji])
end
end
end
describe "timezone handling" do
before do
freeze_time Time.utc(2022, 7, 24, 13, 00)

View File

@ -11,7 +11,7 @@ describe DiscourseCalendar::UpdateHolidayUsernames do
SiteSetting.holiday_calendar_topic_id = calendar_post.topic_id
end
it "works" do
it "adds users on holiday to the users_on_holiday list" do
raw = 'Rome [date="2018-06-05" time="10:20:00"] to [date="2018-06-06" time="10:20:00"]'
post = create_post(raw: raw, topic: calendar_post.topic)
@ -25,4 +25,101 @@ describe DiscourseCalendar::UpdateHolidayUsernames do
expect(DiscourseCalendar.users_on_holiday).to eq([])
end
it "adds custom field to users on holiday" do
raw1 = 'Rome [date="2018-06-05" time="10:20:00"] to [date="2018-06-06" time="10:20:00"]'
post1 = create_post(raw: raw1, topic: calendar_post.topic)
raw2 = 'Rome [date="2018-06-05"]' # the whole day
post2 = create_post(raw: raw2, topic: calendar_post.topic)
freeze_time Time.utc(2018, 6, 5, 10, 30)
subject.execute(nil)
expect(UserCustomField.exists?(name: DiscourseCalendar::HOLIDAY_CUSTOM_FIELD, user_id: post1.user.id)).to be_truthy
expect(UserCustomField.exists?(name: DiscourseCalendar::HOLIDAY_CUSTOM_FIELD, user_id: post2.user.id)).to be_truthy
freeze_time Time.utc(2018, 6, 6, 10, 00)
subject.execute(nil)
expect(UserCustomField.exists?(name: DiscourseCalendar::HOLIDAY_CUSTOM_FIELD, user_id: post1.user.id)).to be_truthy
expect(UserCustomField.exists?(name: DiscourseCalendar::HOLIDAY_CUSTOM_FIELD, user_id: post2.user.id)).to be_falsey
freeze_time Time.utc(2018, 6, 7, 10, 00)
subject.execute(nil)
expect(UserCustomField.exists?(name: DiscourseCalendar::HOLIDAY_CUSTOM_FIELD, user_id: post1.user.id)).to be_falsey
expect(UserCustomField.exists?(name: DiscourseCalendar::HOLIDAY_CUSTOM_FIELD, user_id: post2.user.id)).to be_falsey
end
it "sets status of users on holiday" do
SiteSetting.enable_user_status = true
raw = 'Rome [date="2018-06-05" time="10:20:00"] to [date="2018-06-06" time="10:20:00"]'
post = create_post(raw: raw, topic: calendar_post.topic)
freeze_time Time.utc(2018, 6, 5, 10, 30)
subject.execute(nil)
post.user.reload
status = post.user.user_status
expect(status).to be_present
expect(status.description).to eq(I18n.t("discourse_calendar.holiday_status.description"))
expect(status.emoji).to eq(DiscourseCalendar::HolidayStatus::EMOJI)
expect(status.ends_at).to eq_time(Time.utc(2018, 6, 6, 10, 20))
end
it "doesn't set status of users on holiday if user status is disabled in site settings" do
SiteSetting.enable_user_status = false
raw = 'Rome [date="2018-06-05" time="10:20:00"] to [date="2018-06-06" time="10:20:00"]'
post = create_post(raw: raw, topic: calendar_post.topic)
freeze_time Time.utc(2018, 6, 5, 10, 30)
subject.execute(nil)
post.user.reload
expect(post.user.user_status).to be_nil
end
it "holiday status doesn't override status that was set by a user themselves" do
SiteSetting.enable_user_status = true
raw = 'Rome [date="2018-06-05" time="10:20:00"] to [date="2018-06-06" time="10:20:00"]'
post = create_post(raw: raw, topic: calendar_post.topic)
custom_status = {
description: "I am working on holiday",
emoji: "construction_worker_man"
}
post.user.set_status!(custom_status[:description], custom_status[:emoji])
freeze_time Time.utc(2018, 6, 5, 10, 30)
subject.execute(nil)
post.user.reload
status = post.user.user_status
expect(status).to be_present
expect(status.description).to eq(custom_status[:description])
expect(status.emoji).to eq(custom_status[:emoji])
end
it "updates status' ends_at date when user edits a holiday post" do
SiteSetting.enable_user_status = true
raw = 'Rome [date="2018-06-05" time="10:20:00"] to [date="2018-06-06" time="10:20:00"]'
post = create_post(raw: raw, topic: calendar_post.topic)
freeze_time Time.utc(2018, 6, 5, 10, 30)
subject.execute(nil)
post.user.reload
expect(post.user.user_status).to be_present
expect(post.user.user_status.ends_at).to eq_time(Time.utc(2018, 6, 6, 10, 20))
revisor = PostRevisor.new(post)
revisor.revise!(
post.user,
{ raw: 'Rome [date="2018-06-05" time="10:20:00"] to [date="2018-12-10" time="10:20:00"]' },
revised_at: Time.now
)
subject.execute(nil)
post.user.reload
expect(post.user.user_status).to be_present
expect(post.user.user_status.ends_at).to eq_time(Time.utc(2018, 12, 10, 10, 20))
end
end

View File

@ -0,0 +1,56 @@
# frozen_string_literal: true
require "rails_helper"
describe DiscourseCalendar::UsersOnHoliday do
it "returns users on holiday" do
event1 = Fabricate(:calendar_event, start_date: "2000-01-01")
event2 = Fabricate(:calendar_event, start_date: "2000-01-01")
event3 = Fabricate(:calendar_event, start_date: "2000-01-01")
event4 = Fabricate(:calendar_event, start_date: "2000-01-02")
freeze_time Time.utc(2000, 1, 1, 8, 0)
users_on_holiday = DiscourseCalendar::UsersOnHoliday.from([event1, event2, event3, event4])
usernames = users_on_holiday.values.map { |u| u[:username] }
expect(usernames).to contain_exactly(event1.username, event2.username, event3.username)
end
it "returns empty list if no one is on holiday" do
event1 = Fabricate(:calendar_event, start_date: "2000-01-02")
event2 = Fabricate(:calendar_event, start_date: "2000-01-03")
event3 = Fabricate(:calendar_event, start_date: "2000-01-04")
event4 = Fabricate(:calendar_event, start_date: "2000-01-05")
freeze_time Time.utc(2000, 1, 1, 8, 0)
users_on_holiday = DiscourseCalendar::UsersOnHoliday.from([event1, event2, event3, event4])
expect(users_on_holiday).to be_empty
end
it "ignore events without usernames" do
event1 = Fabricate(:calendar_event, start_date: "2000-01-01")
event2 = Fabricate(:calendar_event, start_date: "2000-01-01")
event3 = Fabricate(:calendar_event, start_date: "2000-01-01", username: nil)
freeze_time Time.utc(2000, 1, 1, 8, 0)
users_on_holiday = DiscourseCalendar::UsersOnHoliday.from([event1, event2, event3])
usernames = users_on_holiday.values.map { |u| u[:username] }
expect(usernames).to contain_exactly(event1.username, event2.username)
end
it "chooses the holiday with the biggest end date if user has several holidays" do
user = Fabricate(:user)
biggest_end_date = "2000-01-04"
event1 = Fabricate(:calendar_event, user: user, start_date: "2000-01-01", end_date: "2000-01-02")
event2 = Fabricate(:calendar_event, user: user, start_date: "2000-01-01", end_date: "2000-01-03")
event3 = Fabricate(:calendar_event, user: user, start_date: "2000-01-01", end_date: biggest_end_date)
freeze_time Time.utc(2000, 1, 1, 8, 0)
users_on_holiday = DiscourseCalendar::UsersOnHoliday.from([event1, event2, event3])
expect(users_on_holiday.length).to be(1)
expect(users_on_holiday.values[0][:ends_at]).to eq(biggest_end_date)
end
end

View File

@ -1,81 +0,0 @@
import {
acceptance,
exists,
query,
} from "discourse/tests/helpers/qunit-helpers";
import { test } from "qunit";
import { visit } from "@ember/test-helpers";
acceptance("Discourse Calendar - Holiday Flair", function (needs) {
needs.user();
needs.settings({ calendar_enabled: true });
needs.site({
users_on_holiday: ["foo", "eviltrout"],
});
needs.pretender((server, helper) => {
server.get("/directory_items", () => {
return helper.response({
directory_items: [
{
id: 1,
likes_received: 0,
likes_given: 0,
topics_entered: 0,
topic_count: 0,
post_count: 0,
posts_read: 0,
days_visited: 1,
user: {
id: 1,
username: "foo",
name: "Foo",
avatar_template:
"/letter_avatar_proxy/v4/letter/f/3be4f8/{size}.png",
},
},
{
id: 2,
likes_received: 0,
likes_given: 0,
topics_entered: 0,
topic_count: 0,
post_count: 0,
posts_read: 0,
days_visited: 1,
user: {
id: 2,
username: "bar",
name: "Bar",
avatar_template:
"/letter_avatar_proxy/v4/letter/b/3be4f8/{size}.png",
},
},
],
meta: {
last_updated_at: "2020-01-01T12:00:00.000Z",
total_rows_directory_items: 2,
load_more_directory_items:
"/directory_items?order=likes_received&page=1&period=weekly",
},
});
});
});
test("shows holiday emoji in directory", async function (assert) {
await visit("/u");
assert.ok(exists(".holiday-flair"));
assert.ok(exists("div[data-username='foo'] .holiday-flair"));
assert.ok(!exists("div[data-username='bar'] .holiday-flair"));
});
test("shows holiday emoji on mention", async function (assert) {
await visit("/t/1-3-0beta9-no-rate-limit-popups/28830");
assert.ok(exists(".mention.on-holiday img.on-holiday"));
assert.strictEqual(
query(".mention.on-holiday").innerText.trim(),
"@eviltrout"
);
});
});