From 05f55dbc1001227261c24d055e8b37a685fa9625 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Sun, 11 Dec 2016 23:36:15 +0800 Subject: [PATCH] FEATURE: Group logs. --- .../components/group-logs-filter.js.es6 | 21 +++ .../components/group-logs-row.js.es6 | 14 ++ .../discourse/controllers/group-logs.js.es6 | 57 ++++++++ .../discourse/controllers/group.js.es6 | 9 +- .../discourse/models/group-history.js.es6 | 9 ++ .../javascripts/discourse/models/group.js.es6 | 10 ++ .../discourse/routes/app-route-map.js.es6 | 1 + .../discourse/routes/group-logs.js.es6 | 20 +++ .../components/group-logs-filter.hbs | 6 + .../templates/components/group-logs-row.hbs | 59 +++++++++ .../discourse/templates/group-logs.hbs | 33 +++++ .../javascripts/discourse/templates/group.hbs | 1 + app/assets/stylesheets/common/base/group.scss | 33 +++++ app/assets/stylesheets/mobile/group.scss | 4 + app/controllers/admin/groups_controller.rb | 10 +- app/controllers/admin/users_controller.rb | 7 +- app/controllers/groups_controller.rb | 27 +++- .../regular/automatic_group_membership.rb | 6 +- app/models/group.rb | 4 +- app/models/group_history.rb | 72 ++++++++++ app/models/invite.rb | 12 +- app/models/user.rb | 8 +- .../basic_group_history_serializer.rb | 14 ++ app/services/group_action_logger.rb | 78 +++++++++++ config/locales/client.en.yml | 19 +++ config/routes.rb | 1 + .../20161208064834_create_group_histories.rb | 19 +++ lib/guardian/group_guardian.rb | 7 +- .../admin/groups_controller_spec.rb | 5 +- .../admin/users_controller_spec.rb | 8 +- spec/fabricators/group_history_fabricator.rb | 6 + spec/integration/groups_spec.rb | 123 +++++++++++++++--- spec/models/group_history_spec.rb | 73 +++++++++++ spec/models/user_spec.rb | 6 + spec/services/group_action_logger_spec.rb | 77 +++++++++++ .../acceptance/groups-logs-test.js.es6 | 40 ++++++ .../javascripts/acceptance/groups-test.js.es6 | 3 +- 37 files changed, 857 insertions(+), 45 deletions(-) create mode 100644 app/assets/javascripts/discourse/components/group-logs-filter.js.es6 create mode 100644 app/assets/javascripts/discourse/components/group-logs-row.js.es6 create mode 100644 app/assets/javascripts/discourse/controllers/group-logs.js.es6 create mode 100644 app/assets/javascripts/discourse/models/group-history.js.es6 create mode 100644 app/assets/javascripts/discourse/routes/group-logs.js.es6 create mode 100644 app/assets/javascripts/discourse/templates/components/group-logs-filter.hbs create mode 100644 app/assets/javascripts/discourse/templates/components/group-logs-row.hbs create mode 100644 app/assets/javascripts/discourse/templates/group-logs.hbs create mode 100644 app/models/group_history.rb create mode 100644 app/serializers/basic_group_history_serializer.rb create mode 100644 app/services/group_action_logger.rb create mode 100644 db/migrate/20161208064834_create_group_histories.rb create mode 100644 spec/fabricators/group_history_fabricator.rb create mode 100644 spec/models/group_history_spec.rb create mode 100644 spec/services/group_action_logger_spec.rb create mode 100644 test/javascripts/acceptance/groups-logs-test.js.es6 diff --git a/app/assets/javascripts/discourse/components/group-logs-filter.js.es6 b/app/assets/javascripts/discourse/components/group-logs-filter.js.es6 new file mode 100644 index 00000000000..ba4ccc3f742 --- /dev/null +++ b/app/assets/javascripts/discourse/components/group-logs-filter.js.es6 @@ -0,0 +1,21 @@ +import computed from 'ember-addons/ember-computed-decorators'; + +export default Ember.Component.extend({ + tagName: '', + + @computed('type') + label(type) { + return I18n.t(`groups.logs.${type}`); + }, + + @computed('value', 'type') + filterText(value, type) { + return type === 'action' ? I18n.t(`group_histories.actions.${value}`) : value; + }, + + actions: { + clearFilter(param) { + this.sendAction("clearFilter", param); + } + } +}); diff --git a/app/assets/javascripts/discourse/components/group-logs-row.js.es6 b/app/assets/javascripts/discourse/components/group-logs-row.js.es6 new file mode 100644 index 00000000000..5b46af4d61d --- /dev/null +++ b/app/assets/javascripts/discourse/components/group-logs-row.js.es6 @@ -0,0 +1,14 @@ +export default Ember.Component.extend({ + tagName: '', + expandDetails: false, + + actions: { + toggleDetails() { + this.toggleProperty('expandDetails'); + }, + + filter(params) { + this.set(`filters.${params.key}`, params.value); + } + } +}); diff --git a/app/assets/javascripts/discourse/controllers/group-logs.js.es6 b/app/assets/javascripts/discourse/controllers/group-logs.js.es6 new file mode 100644 index 00000000000..0019127c3a6 --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/group-logs.js.es6 @@ -0,0 +1,57 @@ +import { default as computed, observes } from 'ember-addons/ember-computed-decorators'; + +export default Ember.Controller.extend({ + group: Ember.inject.controller(), + loading: false, + offset: 0, + + init() { + this._super(); + this.set('filters', Ember.Object.create()); + }, + + @computed('filters.action', 'filters.acting_user', 'filters.target_user', 'filters.subject') + filterParams(action, acting_user, target_user, subject) { + return { action, acting_user, target_user, subject }; + }, + + @observes('filters.action', 'filters.acting_user', 'filters.target_user', 'filters.subject') + _refreshModel() { + this.get('group.model').findLogs(0, this.get('filterParams')).then(results => { + this.set('offset', 0); + + this.get('model').setProperties({ + logs: results.logs, + all_loaded: results.all_loaded + }); + }); + }, + + reset() { + this.setProperties({ + offset: 0, + filters: Ember.Object.create() + }); + }, + + actions: { + loadMore() { + if (this.get('model.all_loaded')) return; + + this.set('loading', true); + + this.get('group.model').findLogs(this.get('offset') + 1, this.get('filterParams')) + .then(results => { + results.logs.forEach(result => this.get('model.logs').addObject(result)); + this.incrementProperty('offset'); + this.set('model.all_loaded', results.all_loaded); + }).finally(() => { + this.set('loading', false); + }); + }, + + clearFilter(key) { + this.set(`filters.${key}`, ''); + } + } +}); diff --git a/app/assets/javascripts/discourse/controllers/group.js.es6 b/app/assets/javascripts/discourse/controllers/group.js.es6 index c4c4eae3267..800097d86db 100644 --- a/app/assets/javascripts/discourse/controllers/group.js.es6 +++ b/app/assets/javascripts/discourse/controllers/group.js.es6 @@ -6,9 +6,9 @@ var Tab = Em.Object.extend({ return 'group.' + name; }, - @computed('name') - message(name) { - return I18n.t('groups.' + name); + @computed('name', 'i18nKey') + message(name, i18nKey) { + return I18n.t(`groups.${i18nKey || name}`); } }); @@ -20,7 +20,8 @@ export default Ember.Controller.extend({ Tab.create({ name: 'posts' }), Tab.create({ name: 'topics' }), Tab.create({ name: 'mentions' }), - Tab.create({ name: 'messages', requiresMembership: true }) + Tab.create({ name: 'messages', requiresMembership: true }), + Tab.create({ name: 'logs', i18nKey: 'logs.title', icon: 'shield', requiresMembership: true }) ], @computed('model.is_group_owner', 'model.automatic') diff --git a/app/assets/javascripts/discourse/models/group-history.js.es6 b/app/assets/javascripts/discourse/models/group-history.js.es6 new file mode 100644 index 00000000000..18b644d163c --- /dev/null +++ b/app/assets/javascripts/discourse/models/group-history.js.es6 @@ -0,0 +1,9 @@ +import computed from 'ember-addons/ember-computed-decorators'; +import RestModel from 'discourse/models/rest'; + +export default RestModel.extend({ + @computed('action') + actionTitle(action) { + return I18n.t(`group_histories.actions.${action}`); + } +}); diff --git a/app/assets/javascripts/discourse/models/group.js.es6 b/app/assets/javascripts/discourse/models/group.js.es6 index 9676cf18100..7d8a58cec66 100644 --- a/app/assets/javascripts/discourse/models/group.js.es6 +++ b/app/assets/javascripts/discourse/models/group.js.es6 @@ -1,5 +1,6 @@ import { ajax } from 'discourse/lib/ajax'; import computed from "ember-addons/ember-computed-decorators"; +import GroupHistory from 'discourse/models/group-history'; const Group = Discourse.Model.extend({ limit: 50, @@ -141,6 +142,15 @@ const Group = Discourse.Model.extend({ return ajax("/admin/groups/" + this.get('id'), { type: "DELETE" }); }, + findLogs(offset, filters) { + return ajax(`/groups/${this.get('name')}/logs.json`, { data: { offset, filters } }).then(results => { + return Ember.Object.create({ + logs: results["logs"].map(log => GroupHistory.create(log)), + all_loaded: results["all_loaded"] + }); + }); + }, + findPosts(opts) { opts = opts || {}; diff --git a/app/assets/javascripts/discourse/routes/app-route-map.js.es6 b/app/assets/javascripts/discourse/routes/app-route-map.js.es6 index a1de0bfc74e..37733386f1f 100644 --- a/app/assets/javascripts/discourse/routes/app-route-map.js.es6 +++ b/app/assets/javascripts/discourse/routes/app-route-map.js.es6 @@ -56,6 +56,7 @@ export default function() { this.route('topics'); this.route('mentions'); this.route('messages'); + this.route('logs'); }); // User routes diff --git a/app/assets/javascripts/discourse/routes/group-logs.js.es6 b/app/assets/javascripts/discourse/routes/group-logs.js.es6 new file mode 100644 index 00000000000..0d365fab188 --- /dev/null +++ b/app/assets/javascripts/discourse/routes/group-logs.js.es6 @@ -0,0 +1,20 @@ +export default Ember.Route.extend({ + titleToken() { + return I18n.t('groups.logs'); + }, + + model() { + return this.modelFor('group').findLogs(); + }, + + setupController(controller, model) { + this.controllerFor('group-logs').setProperties({ model }); + this.controllerFor("group").set("showing", 'logs'); + }, + + actions: { + willTransition() { + this.controllerFor('group-logs').reset(); + } + } +}); diff --git a/app/assets/javascripts/discourse/templates/components/group-logs-filter.hbs b/app/assets/javascripts/discourse/templates/components/group-logs-filter.hbs new file mode 100644 index 00000000000..11c1eb7b558 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/group-logs-filter.hbs @@ -0,0 +1,6 @@ +{{#if value}} + {{#d-button class="btn-small group-logs-filter" action="clearFilter" actionParam=type}} + {{label}}: {{filterText}} + {{fa-icon "times-circle"}} + {{/d-button}} +{{/if}} diff --git a/app/assets/javascripts/discourse/templates/components/group-logs-row.hbs b/app/assets/javascripts/discourse/templates/components/group-logs-row.hbs new file mode 100644 index 00000000000..31d63039952 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/group-logs-row.hbs @@ -0,0 +1,59 @@ + + + {{#d-button class="btn-small" action="filter" actionParam=(hash value=log.action key="action")}} + {{log.actionTitle}} + {{/d-button}} + + + + {{avatar log.acting_user imageSize='tiny'}} + + {{#d-button class="btn-small" action="filter" actionParam=(hash value=log.acting_user.username key="acting_user")}} + {{log.acting_user.username}} + {{/d-button}} + + + + {{#if log.target_user}} + {{avatar log.target_user imageSize='tiny'}} + + {{#d-button class="btn-small" action="filter" actionParam=(hash value=log.target_user.username key="target_user")}} + {{log.target_user.username}} + {{/d-button}} + {{/if}} + + + + {{#if log.subject}} + {{#d-button class="btn-small" action="filter" actionParam=(hash value=log.subject key="subject")}} + {{log.subject}} + {{/d-button}} + {{/if}} + + + {{bound-date log.created_at}} + + + {{#if log.prev_value}} + {{#if expandDetails}} + {{fa-icon 'ellipsis-v'}} + {{else}} + {{fa-icon 'ellipsis-h'}} + {{/if}} + {{/if}} + + + +{{#if expandDetails}} + + +

+ {{i18n 'groups.logs.from'}}: {{log.prev_value}} +

+ +

+ {{i18n 'groups.logs.to'}}: {{log.new_value}} +

+ + +{{/if}} diff --git a/app/assets/javascripts/discourse/templates/group-logs.hbs b/app/assets/javascripts/discourse/templates/group-logs.hbs new file mode 100644 index 00000000000..a43424da344 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/group-logs.hbs @@ -0,0 +1,33 @@ +{{#if model}} +
+ {{group-logs-filter clearFilter="clearFilter" value=filters.action type="action"}} + {{group-logs-filter clearFilter="clearFilter" value=filters.acting_user type="acting_user"}} + {{group-logs-filter clearFilter="clearFilter" value=filters.target_user type="target_user"}} + {{group-logs-filter clearFilter="clearFilter" value=filters.subject type="subject"}} +
+ + {{#load-more selector=".group-logs .group-logs-row" action="loadMore"}} + + + + + + + + + + + + {{#each model.logs as |log|}} + {{group-logs-row + log=log + filters=filters}} + {{/each}} + +
{{i18n 'groups.logs.action'}}{{i18n 'groups.logs.acting_user'}}{{i18n 'groups.logs.target_user'}}{{i18n 'groups.logs.subject'}}{{i18n 'groups.logs.when'}}
+ {{/load-more}} + + {{conditional-loading-spinner condition=loading}} +{{else}} +
{{i18n "groups.empty.logs"}}
+{{/if}} diff --git a/app/assets/javascripts/discourse/templates/group.hbs b/app/assets/javascripts/discourse/templates/group.hbs index 3b20460b2c8..f7188592d2b 100644 --- a/app/assets/javascripts/discourse/templates/group.hbs +++ b/app/assets/javascripts/discourse/templates/group.hbs @@ -41,6 +41,7 @@ {{#each getTabs as |tab|}}
  • {{#link-to tab.location model title=tab.message}} + {{#if tab.icon}}{{fa-icon tab.icon}}{{/if}} {{tab.message}} {{#if tab.count}}({{tab.count}}){{/if}} {{/link-to}} diff --git a/app/assets/stylesheets/common/base/group.scss b/app/assets/stylesheets/common/base/group.scss index f2b48532aff..ef9ee506f60 100644 --- a/app/assets/stylesheets/common/base/group.scss +++ b/app/assets/stylesheets/common/base/group.scss @@ -15,6 +15,39 @@ margin-bottom: 30px; } +.group-logs-filter { + margin-right: 10px; + + &:hover { + background-color: $danger; + } +} + +table.group-logs { + width: 100%; + + th, tr { + border-bottom: 1px solid dark-light-diff($primary, $secondary, 90%, -60%); + } + + th { + text-align: left; + padding: 5px 0px; + } + + td { + padding: 10px 0px; + } + + .group-logs-expand-details { + cursor: pointer; + + i { + color: dark-light-diff($primary, $secondary, 50%, -50%); + } + } +} + table.group-members { width: 100%; table-layout: fixed; diff --git a/app/assets/stylesheets/mobile/group.scss b/app/assets/stylesheets/mobile/group.scss index 120e980d0f1..c254518732c 100644 --- a/app/assets/stylesheets/mobile/group.scss +++ b/app/assets/stylesheets/mobile/group.scss @@ -32,6 +32,10 @@ background-color: $quaternary; } +table.group-logs { + width: 130%; +} + table.group-members { th { text-align: center; diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb index f98b43e0e91..7f4109277e6 100644 --- a/app/controllers/admin/groups_controller.rb +++ b/app/controllers/admin/groups_controller.rb @@ -45,7 +45,7 @@ class Admin::GroupsController < Admin::AdminController # group rename is ignored for automatic groups group.name = group_params[:name] if group_params[:name] && !group.automatic - save_group(group) + save_group(group) { |g| GroupActionLogger.new(current_user, g).log_change_group_settings } end def save_group(group) @@ -72,6 +72,9 @@ class Admin::GroupsController < Admin::AdminController if group.save Group.reset_counters(group.id, :group_users) + + yield(group) if block_given? + render_serialized(group, BasicGroupSerializer) else render_json_error group @@ -101,10 +104,14 @@ class Admin::GroupsController < Admin::AdminController users = User.where(username: params[:usernames].split(",")) users.each do |user| + group_action_logger = GroupActionLogger.new(current_user, group) + if !group.users.include?(user) group.add(user) + group_action_logger.log_add_user_to_group(user) end group.group_users.where(user_id: user.id).update_all(owner: true) + group_action_logger.log_make_user_group_owner(user) end Group.reset_counters(group.id, :group_users) @@ -118,6 +125,7 @@ class Admin::GroupsController < Admin::AdminController user = User.find(params[:user_id].to_i) group.group_users.where(user_id: user.id).update_all(owner: false) + GroupActionLogger.new(current_user, group).log_remove_user_as_group_owner(user) Group.reset_counters(group.id, :group_users) diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 855e3ab6384..c8702c28c50 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -127,8 +127,8 @@ class Admin::UsersController < Admin::AdminController group = Group.find(params[:group_id].to_i) return render_json_error group unless group && !group.automatic - # We don't care about duplicate group assignment - group.users << @user rescue ActiveRecord::RecordNotUnique + group.add(@user) + GroupActionLogger.new(current_user, group).log_add_user_to_group(@user) render nothing: true end @@ -136,7 +136,8 @@ class Admin::UsersController < Admin::AdminController def remove_group group = Group.find(params[:group_id].to_i) return render_json_error group unless group && !group.automatic - group.users.delete(@user) + group.remove(@user) + GroupActionLogger.new(current_user, group).log_remove_user_from_group(user) render nothing: true end diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 346c0ae5dad..cfb8b686743 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -3,7 +3,9 @@ class GroupsController < ApplicationController before_filter :ensure_logged_in, only: [ :set_notifications, :mentionable, - :update + :update, + :messages, + :histories ] skip_before_filter :preload_json, :check_xhr, only: [:posts_feed, :mentions_feed] @@ -17,6 +19,8 @@ class GroupsController < ApplicationController guardian.ensure_can_edit!(group) if group.update_attributes(group_params) + GroupActionLogger.new(current_user, group).log_change_group_settings + render json: success_json else render_json_error(group) @@ -135,6 +139,7 @@ class GroupsController < ApplicationController users.each do |user| if !group.users.include?(user) group.add(user) + GroupActionLogger.new(current_user, group).log_add_user_to_group(user) else return render_json_error I18n.t('groups.errors.member_already_exist', username: user.username) end @@ -184,7 +189,8 @@ class GroupsController < ApplicationController user.primary_group_id = nil if user.primary_group_id == group.id - group.users.delete(user.id) + group.remove(user) + GroupActionLogger.new(current_user, group).log_remove_user_from_group(user) if group.save && user.save render json: success_json @@ -205,6 +211,23 @@ class GroupsController < ApplicationController render json: success_json end + def histories + group = find_group(:group_id) + guardian.ensure_can_edit!(group) + + page_size = 25 + offset = (params[:offset] && params[:offset].to_i) || 0 + + group_histories = GroupHistory.with_filters(group, params[:filters]) + .limit(page_size) + .offset(offset * page_size) + + render_json_dump( + logs: serialize_data(group_histories, BasicGroupHistorySerializer), + all_loaded: group_histories.count < page_size + ) + end + private def group_params diff --git a/app/jobs/regular/automatic_group_membership.rb b/app/jobs/regular/automatic_group_membership.rb index e66942fbce0..a6220db4277 100644 --- a/app/jobs/regular/automatic_group_membership.rb +++ b/app/jobs/regular/automatic_group_membership.rb @@ -16,11 +16,9 @@ module Jobs User.where("email ~* '@(#{domains})$'") .where("users.id NOT IN (SELECT user_id FROM group_users WHERE group_users.group_id = ?)", group_id) .find_each do |user| - begin + group.add(user) - rescue ActiveRecord::RecordNotUnique, PG::UniqueViolation - # we don't care about this - end + GroupActionLogger.new(Discourse.system_user, group).log_add_user_to_group(user) end Group.reset_counters(group.id, :group_users) diff --git a/app/models/group.rb b/app/models/group.rb index fc57ad0c346..44391dc96dd 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -9,6 +9,7 @@ class Group < ActiveRecord::Base has_many :categories, through: :category_groups has_many :users, through: :group_users + has_many :group_histories, dependent: :destroy has_and_belongs_to_many :web_hooks @@ -347,7 +348,8 @@ class Group < ActiveRecord::Base end def add(user) - self.users.push(user) + self.users.push(user) unless self.users.include?(user) + self end def remove(user) diff --git a/app/models/group_history.rb b/app/models/group_history.rb new file mode 100644 index 00000000000..338cf75575a --- /dev/null +++ b/app/models/group_history.rb @@ -0,0 +1,72 @@ +class GroupHistory < ActiveRecord::Base + belongs_to :group + belongs_to :acting_user, class_name: 'User' + belongs_to :target_user, class_name: 'User' + + validates :acting_user_id, presence: true + validates :group_id, presence: true + validates :action, presence: true + + def self.actions + @actions ||= Enum.new( + change_group_setting: 1, + add_user_to_group: 2, + remove_user_from_group: 3, + make_user_group_owner: 4, + remove_user_as_group_owner: 5 + ) + end + + def self.filters + [ + :acting_user, + :target_user, + :action, + :subject + ] + end + + def self.with_filters(group, params = {}) + records = self.includes(:acting_user, :target_user) + .where(group_id: group.id) + .order('group_histories.created_at DESC') + + if !params.blank? + params = params.slice(*filters) + records = records.where(action: self.actions[params[:action].to_sym]) unless params[:action].blank? + records = records.where(subject: params[:subject]) unless params[:subject].blank? + + [:acting_user, :target_user].each do |filter| + unless params[filter].blank? + id = User.where(username_lower: params[filter]).pluck(:id) + records = records.where("#{filter}_id" => id) + end + end + end + + records + end +end + +# == Schema Information +# +# Table name: group_histories +# +# id :integer not null, primary key +# group_id :integer not null +# acting_user_id :integer not null +# target_user_id :integer +# action :integer not null +# subject :string +# prev_value :text +# new_value :text +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_group_histories_on_acting_user_id (acting_user_id) +# index_group_histories_on_action (action) +# index_group_histories_on_group_id (group_id) +# index_group_histories_on_target_user_id (target_user_id) +# diff --git a/app/models/invite.rb b/app/models/invite.rb index 8ecef5eda06..6d20f14ed1e 100644 --- a/app/models/invite.rb +++ b/app/models/invite.rb @@ -55,19 +55,15 @@ class Invite < ActiveRecord::Base InviteRedeemer.new(self).redeem unless expired? || destroyed? || !link_valid? end - - def add_groups_for_topic(topic) - if topic.category - (topic.category.groups - groups).each { |group| group.add(user) } - end - end - def self.extend_permissions(topic, user, invited_by) if topic.private_message? topic.grant_permission_to_user(user.email) elsif topic.category && topic.category.groups.any? if Guardian.new(invited_by).can_invite_to?(topic) && !SiteSetting.enable_sso - (topic.category.groups - user.groups).each { |group| group.add(user) } + (topic.category.groups - user.groups).each do |group| + group.add(user) + GroupActionLogger.new(Discourse.system_user, group).log_add_user_to_group(user) + end end end end diff --git a/app/models/user.rb b/app/models/user.rb index d292d4917b4..cd90c63c6c5 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -66,6 +66,9 @@ class User < ActiveRecord::Base belongs_to :uploaded_avatar, class_name: 'Upload' + has_many :acting_group_histories, dependent: :destroy, foreign_key: :acting_user_id, class_name: GroupHistory + has_many :targeted_group_histories, dependent: :destroy, foreign_key: :target_user_id, class_name: GroupHistory + delegate :last_sent_email_address, :to => :email_logs before_validation :strip_downcase_email @@ -927,8 +930,9 @@ class User < ActiveRecord::Base .where("LENGTH(COALESCE(automatic_membership_email_domains, '')) > 0") .each do |group| domains = group.automatic_membership_email_domains.gsub('.', '\.') - if self.email =~ Regexp.new("@(#{domains})$", true) - group.add(self) rescue ActiveRecord::RecordNotUnique + if self.email =~ Regexp.new("@(#{domains})$", true) && !group.users.include?(self) + group.add(self) + GroupActionLogger.new(Discourse.system_user, group).log_add_user_to_group(self) end end end diff --git a/app/serializers/basic_group_history_serializer.rb b/app/serializers/basic_group_history_serializer.rb new file mode 100644 index 00000000000..f413dfb8cae --- /dev/null +++ b/app/serializers/basic_group_history_serializer.rb @@ -0,0 +1,14 @@ +class BasicGroupHistorySerializer < ApplicationSerializer + attributes :action, + :subject, + :prev_value, + :new_value, + :created_at + + has_one :acting_user, embed: :objects, serializer: BasicUserSerializer + has_one :target_user, embed: :objects, serializer: BasicUserSerializer + + def action + GroupHistory.actions[object.action] + end +end diff --git a/app/services/group_action_logger.rb b/app/services/group_action_logger.rb new file mode 100644 index 00000000000..1518c0d0138 --- /dev/null +++ b/app/services/group_action_logger.rb @@ -0,0 +1,78 @@ +class GroupActionLogger + def initialize(acting_user, group) + @acting_user = acting_user + @group = group + end + + def log_make_user_group_owner(target_user) + can_edit? + + GroupHistory.create!(default_params.merge( + action: GroupHistory.actions[:make_user_group_owner], + target_user: target_user + )) + end + + def log_remove_user_as_group_owner(target_user) + can_edit? + + GroupHistory.create!(default_params.merge( + action: GroupHistory.actions[:remove_user_as_group_owner], + target_user: target_user + )) + end + + def log_add_user_to_group(target_user) + @group.public || can_edit? + + GroupHistory.create!(default_params.merge( + action: GroupHistory.actions[:add_user_to_group], + target_user: target_user + )) + end + + def log_remove_user_from_group(target_user) + @group.public || can_edit? + + GroupHistory.create!(default_params.merge( + action: GroupHistory.actions[:remove_user_from_group], + target_user: target_user + )) + end + + def log_change_group_settings + can_edit? + + @group.previous_changes.except(*excluded_attributes).each do |attribute_name, value| + next if value[0].blank? && value[1].blank? + + GroupHistory.create!(default_params.merge( + action: GroupHistory.actions[:change_group_setting], + subject: attribute_name, + prev_value: value[0], + new_value: value[1] + )) + end + end + + private + + def excluded_attributes + [ + :bio_cooked, + :updated_at, + :created_at, + :user_count + ] + end + + def default_params + { group: @group, acting_user: @acting_user } + end + + def can_edit? + unless Guardian.new(@acting_user).can_log_group_changes?(@group) + raise Discourse::InvalidParameter + end + end +end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 1e7081eda17..cb0c4bc99f4 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -375,7 +375,25 @@ en: one: "1 user" other: "%{count} users" + group_histories: + actions: + change_group_setting: "Change group setting" + add_user_to_group: "Add user" + remove_user_from_group: "Remove user" + make_user_group_owner: "Make owner" + remove_user_as_group_owner: "Revoke owner" + groups: + logs: + title: "Logs" + when: "When" + action: "Action" + acting_user: "Acting user" + target_user: "Target user" + subject: "Subject" + details: "Details" + from: "From" + to: "To" edit: title: 'Edit Group' group_title: 'Title' @@ -387,6 +405,7 @@ en: mentions: "There is no mention of this group." messages: "There is no message for this group." topics: "There is no topic by members of this group." + logs: "There is no logs for this group." add: "Add" join: "Join Group" leave: "Leave Group" diff --git a/config/routes.rb b/config/routes.rb index 5bffa05f745..e3bfdb39b28 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -410,6 +410,7 @@ Discourse::Application.routes.draw do get 'messages' get 'counts' get 'mentionable' + get 'logs' => 'groups#histories' member do put "members" => "groups#add_members" diff --git a/db/migrate/20161208064834_create_group_histories.rb b/db/migrate/20161208064834_create_group_histories.rb new file mode 100644 index 00000000000..1b18971c4ed --- /dev/null +++ b/db/migrate/20161208064834_create_group_histories.rb @@ -0,0 +1,19 @@ +class CreateGroupHistories < ActiveRecord::Migration + def change + create_table :group_histories do |t| + t.integer :group_id, null: false + t.integer :acting_user_id, null: false + t.integer :target_user_id + t.integer :action, index: true, null: false + t.string :subject + t.text :prev_value + t.text :new_value + + t.timestamps null: false + end + + add_index :group_histories, :group_id + add_index :group_histories, :acting_user_id + add_index :group_histories, :target_user_id + end +end diff --git a/lib/guardian/group_guardian.rb b/lib/guardian/group_guardian.rb index 8cc3ffa3899..95ec4f2fdef 100644 --- a/lib/guardian/group_guardian.rb +++ b/lib/guardian/group_guardian.rb @@ -5,11 +5,14 @@ module GroupGuardian # Automatic groups are not represented in the GROUP_USERS # table and thus do not allow membership changes. def can_edit_group?(group) - (is_admin? || group.users.where('group_users.owner').include?(user)) && !group.automatic + can_log_group_changes?(group) && !group.automatic + end + + def can_log_group_changes?(group) + (is_admin? || group.users.where('group_users.owner').include?(user)) end def can_see_group_messages?(group) is_admin? || group.users.include?(user) end - end diff --git a/spec/controllers/admin/groups_controller_spec.rb b/spec/controllers/admin/groups_controller_spec.rb index d3aa8418251..a5b696932fe 100644 --- a/spec/controllers/admin/groups_controller_spec.rb +++ b/spec/controllers/admin/groups_controller_spec.rb @@ -85,7 +85,10 @@ describe Admin::GroupsController do context ".update" do it "ignore name change on automatic group" do - xhr :put, :update, { id: 1, group: { name: "WAT", visible: "true" } } + expect do + xhr :put, :update, { id: 1, group: { name: "WAT", visible: "true" } } + end.to_not change { GroupHistory.count } + expect(response).to be_success group = Group.find(1) diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb index ac851a9c9fd..912338f262c 100644 --- a/spec/controllers/admin/users_controller_spec.rb +++ b/spec/controllers/admin/users_controller_spec.rb @@ -178,10 +178,16 @@ describe Admin::UsersController do it 'adds the user to the group' do xhr :post, :add_group, group_id: group.id, user_id: user.id - expect(response).to be_success + expect(response).to be_success expect(GroupUser.where(user_id: user.id, group_id: group.id).exists?).to eq(true) + group_history = GroupHistory.last + + expect(group_history.action).to eq(GroupHistory.actions[:add_user_to_group]) + expect(group_history.acting_user).to eq(@user) + expect(group_history.target_user).to eq(user) + # Doing it again doesn't raise an error xhr :post, :add_group, group_id: group.id, user_id: user.id expect(response).to be_success diff --git a/spec/fabricators/group_history_fabricator.rb b/spec/fabricators/group_history_fabricator.rb new file mode 100644 index 00000000000..9b5b44c056b --- /dev/null +++ b/spec/fabricators/group_history_fabricator.rb @@ -0,0 +1,6 @@ +Fabricator(:group_history) do + group + action GroupHistory.actions[:add_user_to_group] + acting_user { Fabricate(:user) } + target_user { Fabricate(:user) } +end diff --git a/spec/integration/groups_spec.rb b/spec/integration/groups_spec.rb index 3ca20455f9c..e13677f9793 100644 --- a/spec/integration/groups_spec.rb +++ b/spec/integration/groups_spec.rb @@ -48,14 +48,16 @@ describe "Groups" do end it "should be able update the group" do - xhr :put, "/groups/#{group.id}", { group: { - flair_bg_color: 'FFF', - flair_color: 'BBB', - flair_url: 'fa-adjust', - bio_raw: 'testing', - title: 'awesome team', - public: true - } } + expect do + xhr :put, "/groups/#{group.id}", { group: { + flair_bg_color: 'FFF', + flair_color: 'BBB', + flair_url: 'fa-adjust', + bio_raw: 'testing', + title: 'awesome team', + public: true + } } + end.to change { GroupHistory.count }.by(6) expect(response).to be_success @@ -67,6 +69,7 @@ describe "Groups" do expect(group.bio_raw).to eq('testing') expect(group.title).to eq('awesome team') expect(group.public).to eq(true) + expect(GroupHistory.last.subject).to eq('public') end end @@ -218,21 +221,32 @@ describe "Groups" do context 'adding members' do it "can make incremental adds" do user2 = Fabricate(:user) - expect(group.users.count).to eq(1) - xhr :put, "/groups/#{group.id}/members", usernames: user2.username + expect do + xhr :put, "/groups/#{group.id}/members", usernames: user2.username + end.to change { group.users.count }.by(1) expect(response).to be_success - expect(group.reload.users.count).to eq(2) + + group_history = GroupHistory.last + + expect(group_history.action).to eq(GroupHistory.actions[:add_user_to_group]) + expect(group_history.acting_user).to eq(admin) + expect(group_history.target_user).to eq(user2) end it "can make incremental deletes" do - expect(group.users.count).to eq(1) - - xhr :delete, "/groups/#{group.id}/members", username: user.username + expect do + xhr :delete, "/groups/#{group.id}/members", username: user.username + end.to change { group.users.count }.by(-1) expect(response).to be_success - expect(group.reload.users.count).to eq(0) + + group_history = GroupHistory.last + + expect(group_history.action).to eq(GroupHistory.actions[:remove_user_from_group]) + expect(group_history.acting_user).to eq(admin) + expect(group_history.target_user).to eq(user) end it "cannot add members to automatic groups" do @@ -366,4 +380,83 @@ describe "Groups" do end end end + + describe "group histories" do + context 'when user is not signed in' do + it 'should raise the right error' do + expect { xhr :get, "/groups/#{group.name}/logs" } + .to raise_error(Discourse::NotLoggedIn) + end + end + + context 'when user is not a group owner' do + before do + sign_in(user) + end + + it 'should be forbidden' do + xhr :get, "/groups/#{group.name}/logs" + + expect(response).to be_forbidden + end + end + + describe 'viewing history' do + context 'public group' do + before do + group.add_owner(user) + group.update_attributes!(public: true) + GroupActionLogger.new(user, group).log_change_group_settings + sign_in(user) + end + + it 'should allow group owner to view history' do + xhr :get, "/groups/#{group.name}/logs" + + expect(response).to be_success + + result = JSON.parse(response.body)["logs"].first + + expect(result["action"]).to eq(GroupHistory.actions[1].to_s) + expect(result["subject"]).to eq('public') + expect(result["prev_value"]).to eq('f') + expect(result["new_value"]).to eq('t') + end + end + + context 'admin' do + let(:admin) { Fabricate(:admin) } + + before do + sign_in(admin) + end + + it 'should be able to view history' do + GroupActionLogger.new(admin, group).log_remove_user_from_group(user) + + xhr :get, "/groups/#{group.name}/logs" + + expect(response).to be_success + + result = JSON.parse(response.body)["logs"].first + + expect(result["action"]).to eq(GroupHistory.actions[3].to_s) + end + + it 'should be able to filter through the history' do + GroupActionLogger.new(admin, group).log_add_user_to_group(user) + GroupActionLogger.new(admin, group).log_remove_user_from_group(user) + + xhr :get, "/groups/#{group.name}/logs", filters: { "action" => "add_user_to_group" } + + expect(response).to be_success + + logs = JSON.parse(response.body)["logs"] + + expect(logs.count).to eq(1) + expect(logs.first["action"]).to eq(GroupHistory.actions[2].to_s) + end + end + end + end end diff --git a/spec/models/group_history_spec.rb b/spec/models/group_history_spec.rb new file mode 100644 index 00000000000..2b287de4348 --- /dev/null +++ b/spec/models/group_history_spec.rb @@ -0,0 +1,73 @@ +require 'rails_helper' + +RSpec.describe GroupHistory do + let(:group_history) { Fabricate(:group_history) } + + let(:other_group_history) do + Fabricate(:group_history, + action: GroupHistory.actions[:remove_user_from_group], + group: group_history.group + ) + end + + describe '.with_filters' do + it 'should return the right records' do + expect(described_class.with_filters(group_history.group)) + .to eq([group_history]) + end + + it 'should filter by action correctly' do + other_group_history + + expect(described_class.with_filters( + group_history.group, + { :action => GroupHistory.actions[3]} + )).to eq([other_group_history]) + end + + it 'should filter by subject correctly' do + other_group_history.update_attributes!(subject: "test") + + expect(described_class.with_filters( + group_history.group, + subject: 'test' + )).to eq([other_group_history]) + end + + it 'should filter by multiple filters correctly' do + group_history.update_attributes!(action: GroupHistory.actions[:remove_user_from_group]) + other_group_history.update_attributes!(subject: "test") + + expect(described_class.with_filters(group_history.group, + :action => GroupHistory.actions[3], subject: 'test' + )).to eq([other_group_history]) + end + + it 'should filter by target_user and acting_user correctly' do + group_history + other_group_history + + group_history_3 = Fabricate(:group_history, + group: group_history.group, + acting_user: other_group_history.acting_user, + target_user: other_group_history.target_user, + action: GroupHistory.actions[:remove_user_as_group_owner] + ) + + expect(described_class.with_filters( + group_history.group, + target_user: other_group_history.target_user.username + ).sort).to eq([other_group_history, group_history_3]) + + expect(described_class.with_filters( + group_history.group, + acting_user: group_history.acting_user.username + )).to eq([group_history]) + + expect(described_class.with_filters( + group_history.group, + acting_user: group_history_3.acting_user.username, target_user: other_group_history.target_user.username + ).sort).to eq([other_group_history, group_history_3]) + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 31dca6883f0..614cf17697e 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -1163,6 +1163,12 @@ describe User do user = Fabricate(:user, email: "foo@bar.com") group.reload expect(group.users.include?(user)).to eq(true) + + group_history = GroupHistory.last + + expect(group_history.action).to eq(GroupHistory.actions[:add_user_to_group]) + expect(group_history.acting_user).to eq(Discourse.system_user) + expect(group_history.target_user).to eq(user) end end diff --git a/spec/services/group_action_logger_spec.rb b/spec/services/group_action_logger_spec.rb new file mode 100644 index 00000000000..0ec4efe2617 --- /dev/null +++ b/spec/services/group_action_logger_spec.rb @@ -0,0 +1,77 @@ +require 'rails_helper' + +RSpec.describe GroupActionLogger do + let(:group_owner) { Fabricate(:user) } + let(:group) { Fabricate(:group) } + let(:user) { Fabricate(:user) } + + subject { described_class.new(group_owner, group) } + + before do + group.add_owner(group_owner) + end + + describe '#log_make_user_group_owner' do + it 'should create the right record' do + subject.log_make_user_group_owner(user) + + group_history = GroupHistory.last + + expect(group_history.action).to eq(GroupHistory.actions[:make_user_group_owner]) + expect(group_history.acting_user).to eq(group_owner) + expect(group_history.target_user).to eq(user) + end + end + + describe '#log_remove_user_as_group_owner' do + it 'should create the right record' do + subject.log_remove_user_as_group_owner(user) + + group_history = GroupHistory.last + + expect(group_history.action).to eq(GroupHistory.actions[:remove_user_as_group_owner]) + expect(group_history.acting_user).to eq(group_owner) + expect(group_history.target_user).to eq(user) + end + end + + describe '#log_add_user_to_group' do + it 'should create the right record' do + subject.log_add_user_to_group(user) + + group_history = GroupHistory.last + + expect(group_history.action).to eq(GroupHistory.actions[:add_user_to_group]) + expect(group_history.acting_user).to eq(group_owner) + expect(group_history.target_user).to eq(user) + end + end + + describe '#log_remove_user_from_group' do + it 'should create the right record' do + subject.log_remove_user_from_group(user) + + group_history = GroupHistory.last + + expect(group_history.action).to eq(GroupHistory.actions[:remove_user_from_group]) + expect(group_history.acting_user).to eq(group_owner) + expect(group_history.target_user).to eq(user) + end + end + + describe '#log_change_group_settings' do + it 'should create the right record' do + group.update_attributes!(public: true, created_at: Time.zone.now) + + expect { subject.log_change_group_settings }.to change { GroupHistory.count }.by(1) + + group_history = GroupHistory.last + + expect(group_history.action).to eq(GroupHistory.actions[:change_group_setting]) + expect(group_history.acting_user).to eq(group_owner) + expect(group_history.subject).to eq('public') + expect(group_history.prev_value).to eq('f') + expect(group_history.new_value).to eq('t') + end + end +end diff --git a/test/javascripts/acceptance/groups-logs-test.js.es6 b/test/javascripts/acceptance/groups-logs-test.js.es6 new file mode 100644 index 00000000000..478955549df --- /dev/null +++ b/test/javascripts/acceptance/groups-logs-test.js.es6 @@ -0,0 +1,40 @@ +import { acceptance } from "helpers/qunit-helpers"; + +acceptance("Group Logs", { + loggedIn: true, + setup() { + const response = object => { + return [ + 200, + { "Content-Type": "application/json" }, + object + ]; + }; + + server.get('/groups/snorlax.json', () => { // eslint-disable-line no-undef + return response({"basic_group":{"id":41,"automatic":false,"name":"snorlax","user_count":1,"alias_level":0,"visible":true,"automatic_membership_email_domains":"","automatic_membership_retroactive":false,"primary_group":true,"title":"Team Snorlax","grant_trust_level":null,"incoming_email":null,"has_messages":false,"flair_url":"","flair_bg_color":"","flair_color":"","bio_raw":"","bio_cooked":null,"public":true,"is_group_user":true,"is_group_owner":true}}); + }); + + // Workaround while awaiting https://github.com/tildeio/route-recognizer/issues/53 + server.get('/groups/snorlax/logs.json', request => { // eslint-disable-line no-undef + if (request.queryParams["filters[action]"]) { + return response({"logs":[{"action":"change_group_setting","subject":"title","prev_value":null,"new_value":"Team Snorlax","created_at":"2016-12-12T08:27:46.408Z","acting_user":{"id":1,"username":"tgx","avatar_template":"/letter_avatar_proxy/v2/letter/t/ecae2f/{size}.png"},"target_user":null}],"all_loaded":true}); + } else { + return response({"logs":[{"action":"change_group_setting","subject":"title","prev_value":null,"new_value":"Team Snorlax","created_at":"2016-12-12T08:27:46.408Z","acting_user":{"id":1,"username":"tgx","avatar_template":"/letter_avatar_proxy/v2/letter/t/ecae2f/{size}.png"},"target_user":null},{"action":"add_user_to_group","subject":null,"prev_value":null,"new_value":null,"created_at":"2016-12-12T08:27:27.725Z","acting_user":{"id":1,"username":"tgx","avatar_template":"/letter_avatar_proxy/v2/letter/t/ecae2f/{size}.png"},"target_user":{"id":1,"username":"tgx","avatar_template":"/letter_avatar_proxy/v2/letter/t/ecae2f/{size}.png"}}],"all_loaded":true}); + } + }); + } +}); + +test("Browsing group logs", () => { + visit("/groups/snorlax/logs"); + + andThen(() => { + ok(find('tr.group-logs-row').length === 2, 'it should display the right number of logs'); + click(find(".group-logs-row button")[0]); + }); + + andThen(() => { + ok(find('tr.group-logs-row').length === 1, 'it should display the right number of logs'); + }); +}); diff --git a/test/javascripts/acceptance/groups-test.js.es6 b/test/javascripts/acceptance/groups-test.js.es6 index 9ebe4826943..d3ff6e5c221 100644 --- a/test/javascripts/acceptance/groups-test.js.es6 +++ b/test/javascripts/acceptance/groups-test.js.es6 @@ -39,7 +39,8 @@ test("Admin Browsing Groups", () => { visit("/groups/discourse"); andThen(() => { - ok(find('.nav-stacked li').length === 5, 'it should show messages tab if user is admin'); + ok(find(".nav-stacked li a[title='Messages']").length === 1, 'it should show messages tab if user is admin'); + ok(find(".nav-stacked li a[title='Logs']").length === 1, 'it should show Logs tab if user is admin'); equal(find('.group-title').text(), 'Awesome Team', 'it should display the group title'); equal(find('.group-name').text(), '@discourse', 'it should display the group name'); });