From 7478e164200dfcb3b0a1ad6bf021f75d015386c0 Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Fri, 11 Sep 2015 00:12:37 +0200 Subject: [PATCH 01/42] connected groups: implementing StructureableConnectedGroups Example: group1 |---- group2 --- group3 |---- event1 |------ attendees_group In the example, groups 1, 2, and 3 are connected groups. But the attendees_group is not connected to them, because a non-group object, event1, is in between. --- .../structureable_connected_groups.rb | 37 ++++++++++++ app/models/structureable.rb | 4 ++ .../structureable_connected_groups_spec.rb | 59 +++++++++++++++++++ 3 files changed, 100 insertions(+) create mode 100644 app/models/concerns/structureable_connected_groups.rb create mode 100644 spec/models/concerns/structureable_connected_groups_spec.rb diff --git a/app/models/concerns/structureable_connected_groups.rb b/app/models/concerns/structureable_connected_groups.rb new file mode 100644 index 000000000..5aead58d8 --- /dev/null +++ b/app/models/concerns/structureable_connected_groups.rb @@ -0,0 +1,37 @@ +# This extends the Structureabe objects by methods that deal with connected groups, +# which are groups that are related to the structureable object by other groups, +# but not via events or other non-group objects. +# +# Example: +# +# group1 +# |---- group2 --- group3 +# |---- event1 +# |------ attendees_group +# +# In the example, groups 1, 2, and 3 are connected groups. But the attendees_group +# is not connected to them, because a non-group object, event1, is in between. +# +# The here implemented mechanism should be independent of the DagLink model, +# i.e. can only ask for directly connected objects. Therefore, it relies on caching +# rather than indirect graph connections to achieve the neccessary read performance. +# +concern :StructureableConnectedGroups do + + def connected_ancestor_groups + Group.find connected_ancestor_group_ids + end + + def connected_ancestor_group_ids + cached { (parent_group_ids + parent_groups.collect(&:connected_ancestor_group_ids).flatten).uniq } + end + + def connected_descendant_groups + Group.find connected_descendant_group_ids + end + + def connected_descendant_group_ids + cached { (child_group_ids + child_groups.collect(&:connected_descendant_group_ids).flatten).uniq } + end + +end \ No newline at end of file diff --git a/app/models/structureable.rb b/app/models/structureable.rb index c57f1b019..c0c9e2930 100644 --- a/app/models/structureable.rb +++ b/app/models/structureable.rb @@ -68,6 +68,10 @@ def is_structureable( options = {} ) # defined in the module `StructureableInstanceMethods`. # prepend StructureableInstanceMethods + + # Use the connected-groups mechanism. + # + include StructureableConnectedGroups end module StructureableInstanceMethods diff --git a/spec/models/concerns/structureable_connected_groups_spec.rb b/spec/models/concerns/structureable_connected_groups_spec.rb new file mode 100644 index 000000000..2e05b9a9a --- /dev/null +++ b/spec/models/concerns/structureable_connected_groups_spec.rb @@ -0,0 +1,59 @@ +require 'spec_helper' + +describe StructureableConnectedGroups do + + # Example: + # + # group1 + # |---- group2 --- group3 -------------- + # |---- event1 | + # |------ attendees_group ---- user1 + # + # In the example, groups 1, 2, and 3 are connected groups. But the attendees_group + # is not connected to them, because a non-group object, event1, is in between. + # + before do + @group1 = create :group, name: 'group1' + @group2 = @group1.child_groups.create name: 'group2' + @group3 = @group2.child_groups.create name: 'group3' + @event1 = @group1.child_events.create name: 'event1' + @attendees_group = @event1.attendees_group + @user1 = create :user; @group3 << @user1; @attendees_group << @user1 + end + + describe "#connected_ancestor_groups" do + describe "for @group3" do + subject { @group3.connected_ancestor_groups } + it { should include @group1, @group2 } + it { should_not include @group3 } + it { should_not include @attendees_group } + end + + describe "for @group2" do + subject { @group2.connected_ancestor_groups } + it { should include @group1 } + it { should_not include @group2 } + it { should_not include @group3, @attendees_group } + end + + describe "for @attendees_group" do + subject { @attendees_group.connected_ancestor_groups } + it { should == [] } + end + + describe "for @user1" do + subject { @user1.connected_ancestor_groups } + it { should include @group1, @group2, @group3 } + it { should include @attendees_group } + it { should_not include @event1 } + end + end + + describe "#connected_descendant_groups" do + subject { @group1.connected_descendant_groups } + it { should include @group2, @group3 } + it { should_not include @group1 } + it { should_not include @attendees_group } + end + +end \ No newline at end of file From db04f361f43cd0b64dc7b7a8ce7d18549f55e2c9 Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Fri, 11 Sep 2015 01:36:06 +0200 Subject: [PATCH 02/42] connected groups: first attempt to implement a Membership model, which is independent of the indirect DagLink objects. --- app/models/membership.rb | 43 +++++++++++++++++++++++++++ app/models/membership_collection.rb | 41 +++++++++++++++++++++++++ spec/models/membership_spec.rb | 46 +++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 app/models/membership.rb create mode 100644 app/models/membership_collection.rb create mode 100644 spec/models/membership_spec.rb diff --git a/app/models/membership.rb b/app/models/membership.rb new file mode 100644 index 000000000..ff270bad4 --- /dev/null +++ b/app/models/membership.rb @@ -0,0 +1,43 @@ +# This represents a user-group membership. +# +# Example: +# +# group1 --- page1 --- group2 --- group3 --- user1 +# | +# |------- user2 +# +# In the example, user1 has two memberships, one of them direct. +# user2 has one membership. +# +# Membership.where(user: user1).count == 2 +# Membership.where(user: user2).count == 1 +# +class Membership + + attr_accessor :user, :group, :valid_from, :valid_to + + def initialize(attrs = {}) + @user = attrs[:user] + @group = attrs[:group] + @valid_from = attrs[:valid_from] + @valid_to = attrs[:valid_to] + end + + def self.where(constraints = {}) + MembershipCollection.new.where(constraints) + end + + def self.direct + MembershipCollection.new.direct + end + + def ==(other_membership) + self.group.id == other_membership.group.id and + self.user.id = other_membership.user.id and + self.valid_from == other_membership.valid_from and + self.valid_to == other_membership.valid_to + end + + alias_method :eql?, :== + +end \ No newline at end of file diff --git a/app/models/membership_collection.rb b/app/models/membership_collection.rb new file mode 100644 index 000000000..803134964 --- /dev/null +++ b/app/models/membership_collection.rb @@ -0,0 +1,41 @@ +class MembershipCollection + + def where(constraints) + @user = constraints[:user] + @group = constraints[:group] + return self + end + + def direct + @direct = true + return self + end + + def dag_links + links = DagLink.where(ancestor_type: 'Group', descendant_type: 'User', direct: true) + links = links.where(descendant_id: @user.id) if @user + links = links.where(ancestor_id: @group.id) if @group + return links + end + + def to_a + dag_links.collect do |direct_link| + [ Membership.new(user: direct_link.descendant, group: direct_link.ancestor) ] + if @direct + [] + else # also add indirect memberships: + if @user and not @group + direct_link.ancestor.connected_ancestor_groups.collect do |ancestor_group| + Membership.new(user: direct_link.descendant, group: ancestor_group) + end + elsif @group and not @user + direct_link.ancestor.connected_descendant_groups.collect do |descendant_group| + Membership.new(user: direct_link.descendant, group: descendant_group) + end + end + end + end.flatten + end + + delegate :count, :first, :last, to: :to_a + +end \ No newline at end of file diff --git a/spec/models/membership_spec.rb b/spec/models/membership_spec.rb new file mode 100644 index 000000000..182e5b2b4 --- /dev/null +++ b/spec/models/membership_spec.rb @@ -0,0 +1,46 @@ +require 'spec_helper' + +describe Membership do + + # Example: + # + # group1 --- page1 --- group2 --- group3 --- user1 + # | + # |------- user2 + # + before do + @group1 = create :group, name: 'group1' + @page1 = @group1.child_pages.create title: 'page1' + @group2 = @page1.child_groups.create name: 'group2' + @group3 = @group2.child_groups.create name: 'group3' + @user1 = create :user; @group3 << @user1 + @user2 = create :user; @group1 << @user2 + end + + describe ".where(user: @user1)" do + subject { Membership.where(user: @user1) } + it { should be_kind_of MembershipCollection } + its(:to_a) { should be_kind_of Array } + its(:first) { should be_kind_of Membership } + its(:count) { should == 2 } + end + + describe ".where(group: @group3)" do + subject { Membership.where(group: @group3) } + it { should be_kind_of MembershipCollection } + its(:to_a) { should be_kind_of Array } + its(:first) { should be_kind_of Membership } + its(:count) { should == 1 } + end + + describe ".direct" do + it "reduces the scope to direct memberships" do + Membership.where(user: @user1).direct.count.should == 1 + end + + it "should be interchangable" do + Membership.where(user: @user1).direct.to_a.should == Membership.direct.where(user: @user1).to_a + end + end + +end \ No newline at end of file From 8278ce52478a6d2b862ef14fbd124687f3dbbbaa Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Fri, 11 Sep 2015 02:18:02 +0200 Subject: [PATCH 03/42] connected groups: improving mechanism to find Membership objects This allows to find indirect memberships of groups. --- app/models/membership_collection.rb | 45 ++++++++++++++++++++--------- spec/models/membership_spec.rb | 43 ++++++++++++++++++--------- 2 files changed, 62 insertions(+), 26 deletions(-) diff --git a/app/models/membership_collection.rb b/app/models/membership_collection.rb index 803134964..77a8e7b09 100644 --- a/app/models/membership_collection.rb +++ b/app/models/membership_collection.rb @@ -18,24 +18,43 @@ def dag_links return links end + def to_a + if @direct + find_all_direct_memberships + else + if @user and not @group + find_all_memberships_by_user + elsif @group and not @user + find_all_memberships_by_group + end + end + end + + delegate :count, :first, :last, to: :to_a + + private + + def find_all_direct_memberships dag_links.collect do |direct_link| - [ Membership.new(user: direct_link.descendant, group: direct_link.ancestor) ] + if @direct - [] - else # also add indirect memberships: - if @user and not @group - direct_link.ancestor.connected_ancestor_groups.collect do |ancestor_group| - Membership.new(user: direct_link.descendant, group: ancestor_group) - end - elsif @group and not @user - direct_link.ancestor.connected_descendant_groups.collect do |descendant_group| - Membership.new(user: direct_link.descendant, group: descendant_group) - end - end + Membership.new(user: direct_link.descendant, group: direct_link.ancestor) + end + end + + def find_all_memberships_by_user + find_all_direct_memberships.collect do |direct_membership| + [ direct_membership ] + direct_membership.group.connected_ancestor_groups.collect do |ancestor_group| + Membership.new(user: direct_membership.user, group: ancestor_group) end end.flatten end - delegate :count, :first, :last, to: :to_a + def find_all_memberships_by_group + find_all_direct_memberships + @group.connected_descendant_groups.collect do |descendant_group| + DagLink.where(ancestor_type: 'Group', descendant_type: 'User', direct: true, ancestor_id: descendant_group.id).collect do |direct_link| + Membership.new(user: direct_link.descendant, group: descendant_group) + end + end.flatten + end end \ No newline at end of file diff --git a/spec/models/membership_spec.rb b/spec/models/membership_spec.rb index 182e5b2b4..e6e95e930 100644 --- a/spec/models/membership_spec.rb +++ b/spec/models/membership_spec.rb @@ -17,20 +17,37 @@ @user2 = create :user; @group1 << @user2 end - describe ".where(user: @user1)" do - subject { Membership.where(user: @user1) } - it { should be_kind_of MembershipCollection } - its(:to_a) { should be_kind_of Array } - its(:first) { should be_kind_of Membership } - its(:count) { should == 2 } - end + describe ".where" do - describe ".where(group: @group3)" do - subject { Membership.where(group: @group3) } - it { should be_kind_of MembershipCollection } - its(:to_a) { should be_kind_of Array } - its(:first) { should be_kind_of Membership } - its(:count) { should == 1 } + describe ".where(user: @user1)" do + subject { Membership.where(user: @user1) } + it { should be_kind_of MembershipCollection } + its(:to_a) { should be_kind_of Array } + its(:first) { should be_kind_of Membership } + its(:count) { should == 2 } + end + + describe "for groups that have direct members" do + describe ".where(group: @group3)" do + subject { Membership.where(group: @group3) } + it { should be_kind_of MembershipCollection } + its(:to_a) { should be_kind_of Array } + its(:first) { should be_kind_of Membership } + its(:count) { should == 1 } + its('direct.count') { should == 1 } + end + end + + describe "for groups that have indirect members" do + describe ".where(group: @group2)" do + subject { Membership.where(group: @group2) } + it { should be_kind_of MembershipCollection } + its(:to_a) { should be_kind_of Array } + its(:first) { should be_kind_of Membership } + its(:count) { should == 1 } + its('direct.count') { should == 0 } + end + end end describe ".direct" do From 63bd0d1edb51355f0915e37d06a4af81e5aed8e1 Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Fri, 11 Sep 2015 14:28:14 +0200 Subject: [PATCH 04/42] connected groups: find memberships for user and group --- app/models/membership_collection.rb | 32 +++++++++++++++++++++-------- spec/models/membership_spec.rb | 21 ++++++++++++++++++- 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/app/models/membership_collection.rb b/app/models/membership_collection.rb index 77a8e7b09..c61656833 100644 --- a/app/models/membership_collection.rb +++ b/app/models/membership_collection.rb @@ -11,14 +11,6 @@ def direct return self end - def dag_links - links = DagLink.where(ancestor_type: 'Group', descendant_type: 'User', direct: true) - links = links.where(descendant_id: @user.id) if @user - links = links.where(ancestor_id: @group.id) if @group - return links - end - - def to_a if @direct find_all_direct_memberships @@ -27,6 +19,8 @@ def to_a find_all_memberships_by_user elsif @group and not @user find_all_memberships_by_group + elsif @user and @group + find_all_memberships_by_user_and_group end end end @@ -35,6 +29,18 @@ def to_a private + def dag_links_for(attrs = {}) + user = attrs[:user]; group = attrs[:group] + links = DagLink.where(ancestor_type: 'Group', descendant_type: 'User', direct: true) + links = links.where(descendant_id: user.id) if user + links = links.where(ancestor_id: group.id) if group + return links + end + + def dag_links + dag_links_for user: @user, group: @group + end + def find_all_direct_memberships dag_links.collect do |direct_link| Membership.new(user: direct_link.descendant, group: direct_link.ancestor) @@ -51,7 +57,15 @@ def find_all_memberships_by_user def find_all_memberships_by_group find_all_direct_memberships + @group.connected_descendant_groups.collect do |descendant_group| - DagLink.where(ancestor_type: 'Group', descendant_type: 'User', direct: true, ancestor_id: descendant_group.id).collect do |direct_link| + dag_links_for(group: descendant_group).collect do |direct_link| + Membership.new(user: direct_link.descendant, group: descendant_group) + end + end.flatten + end + + def find_all_memberships_by_user_and_group + find_all_direct_memberships + @group.connected_descendant_groups.collect do |descendant_group| + dag_links_for(group: descendant_group, user: @user).collect do |direct_link| Membership.new(user: direct_link.descendant, group: descendant_group) end end.flatten diff --git a/spec/models/membership_spec.rb b/spec/models/membership_spec.rb index e6e95e930..b91f16a1a 100644 --- a/spec/models/membership_spec.rb +++ b/spec/models/membership_spec.rb @@ -18,7 +18,6 @@ end describe ".where" do - describe ".where(user: @user1)" do subject { Membership.where(user: @user1) } it { should be_kind_of MembershipCollection } @@ -48,6 +47,26 @@ its('direct.count') { should == 0 } end end + + describe "for user and group" do + describe "when the link is direct" do + subject { Membership.where(group: @group3, user: @user1) } + it { should be_kind_of MembershipCollection } + its(:to_a) { should be_kind_of Array } + its(:first) { should be_kind_of Membership } + its(:count) { should == 1 } + its('direct.count') { should == 1 } + end + + describe "when the link is not direct" do + subject { Membership.where(group: @group2, user: @user1) } + it { should be_kind_of MembershipCollection } + its(:to_a) { should be_kind_of Array } + its(:first) { should be_kind_of Membership } + its(:count) { should == 1 } + its('direct.count') { should == 0 } + end + end end describe ".direct" do From a609ac601cb6b8336950fd42e4f943a96fc16419 Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Sat, 12 Sep 2015 23:26:57 +0200 Subject: [PATCH 05/42] connected groups: validity range for memberships --- .../concerns/dag_link_validity_range.rb | 201 ++++++++++++++++++ .../membership_collection_validity_range.rb | 164 ++++++++++++++ .../concerns/membership_validity_range.rb | 39 ++++ app/models/dag_link.rb | 1 + app/models/membership.rb | 44 ++++ app/models/membership_collection.rb | 21 +- .../validity_range.rb | 130 +---------- ...mbership_collection_validity_range_spec.rb | 58 +++++ .../membership_validity_range_spec.rb | 64 ++++++ spec/models/membership_spec.rb | 47 +++- 10 files changed, 628 insertions(+), 141 deletions(-) create mode 100644 app/models/concerns/dag_link_validity_range.rb create mode 100644 app/models/concerns/membership_collection_validity_range.rb create mode 100644 app/models/concerns/membership_validity_range.rb create mode 100644 spec/models/concerns/membership_collection_validity_range_spec.rb create mode 100644 spec/models/concerns/membership_validity_range_spec.rb diff --git a/app/models/concerns/dag_link_validity_range.rb b/app/models/concerns/dag_link_validity_range.rb new file mode 100644 index 000000000..0d47c4438 --- /dev/null +++ b/app/models/concerns/dag_link_validity_range.rb @@ -0,0 +1,201 @@ +# +# In this project, user group memberships do not neccessarily last forever. +# They can begin at some time and end at some time. This is expressed by the +# ValidityRange of a membership. +# +# In the database, direct memberships are stored as DagLinks, since we've +# used the acts_as_dag gem earlier. +# +# +# ## Examples +# +# membership.valid_from # => time +# membership.valid_to # => time +# membership.invalidate +# +# +# ## Scopes +# +# The same functionality can be described from tho different perspectives: +# +# From the "validity perspective", a membership can be currently valid or +# invalid. One can filter memberships by their validity status. +# +# From the "time perspective", there are current memberships and past +# memberships. +# +# These perspectives are linked by the fact that "past memberships" are just +# memberships that are currently invalid but have been valid in the past. +# +# Validity Perspective: +# +# UserGroupMembership.valid +# UserGroupMembership.invalid +# UserGroupMembership.with_invalid +# +# Time Perspective: +# +# UserGroupMembership.now +# UserGroupMembership.past +# UserGroupMembership.now_and_past +# UserGroupMembership.now_and_in_the_past +# UserGroupMembership.at_time(time) +# +# Default Scope: +# +# By default, the `valid` scope is applied, i.e. only memberships are +# found that are valid at present time. To override this scope, use the +# either `with_invalid` scope. +# +# +# ## Caveats +# +# * There is only one `valid_from` and one `valid_to` time per object. +# Therefore, you can't keep track of first invalidating an object and +# later re-validating it. Re-validating an object loses the information +# of first invalidating it. +# +# * Currently, the future is not handled (`Article.future` and +# `article.invalidate at: 1.hour.from.now` do not work.) But this is +# planned to be implemented in the future. +# +# * Some functionality has been extracted out into the temporal_scopes gem +# in order to test the scopes easily. But, this code has been abandoned +# since the Rails-4 migration took more time. +# => https://github.com/fiedl/temporal_scopes/blob/master/lib/temporal_scopes/has_temporal_scopes.rb +# +# * Rails 5 supports an `.or(...)` syntax: +# https://github.com/rails/rails/pull/16052 +# TODO: Refactor the queries in the scopes when migrating to Rails 5. +# +concern :DagLinkValidityRange do + + included do + attr_accessible :valid_from, :valid_to, :valid_from_localized_date, :valid_to_localized_date + before_validation :set_valid_from_to_now + + # default_scope { valid } + + # Validity Perspective + # TODO: Allow :valid to include memberships that BECOME valid in the future. + scope :valid, -> { where("valid_from IS NULL OR valid_from <= ?", Time.zone.now).where("valid_to IS NULL OR valid_to >= ?", Time.zone.now) } + scope :invalid, -> { with_invalid.where("valid_to < ?", Time.zone.now) } + # scope :with_invalid # This is defined as method below due to some issues. + scope :only_valid, -> { valid } + scope :only_invalid, -> { invalid } + + # Time Perspective + scope :now, -> { valid } + scope :past, -> { invalid } + scope :in_the_past, -> { invalid } + scope :with_past, -> { with_invalid } + scope :now_and_past, -> { with_invalid } + scope :now_and_in_the_past, -> { with_invalid } + scope :at_time, -> (time) { with_past.where("valid_from IS NULL OR valid_from <= ?", time).where("valid_to IS NULL OR valid_to >= ?", time) } + scope :this_year, -> { with_invalid.where("valid_from >= ?", "#{Time.zone.now.year}-01-01 00:00:00") } + scope :started_after, -> (time) { where('NOT valid_from IS NULL').where("valid_from >= ?", time) } + end + + class_methods do + # This scope widens the query such that also memberships that are not valid + # at the present time are returned. + # + # Have a look at `rewhere`: + # https://github.com/rails/rails/commit/f950b2699f97749ef706c6939a84dfc85f0b05f2#diff-bf6dd6226db3aab589916f09236881c7R562 + # + # But `rewhere` is not enough. We need more filtering: + # https://github.com/fiedl/temporal_scopes/blob/master/lib/temporal_scopes/has_temporal_scopes.rb + # + # TODO: Check if this still needs the extra filter when migrating to Rails 5. + # + def with_invalid + relation = unscope(where: [:valid_from, :valid_to]) + relation.where_values.delete_if { |query| query.to_s.include?("valid_from") || query.to_s.include?("valid_to") } + relation + end + end + + concerning :Invalidation do + # This method ends the membership, i.e. sets the end of the validity range + # to the given time. + # + # The following examples are equivalent (despite the return value): + # + # membership.make_invalid + # membership.make_invalid at: Time.zone.now + # membership.make_invalid Time.zone.now + # membership.invalidate # => membership + # membership.update_attribute :valid_to, Time.zone.now # => true + # + def make_invalid(time = Time.zone.now) + time = time[:at] if time.kind_of?(Hash) && time[:at] + self.update_attribute(:valid_to, time) + return self + end + + # This is just an alias for `make_invalid`. + # + def invalidate(time = Time.zone.now) + self.make_invalid(time) + end + + # This method determines whether the membership can be invalidated. + # Direct memberships can be invalidated, whereas indirect memberships cannot. + # The validity of indirect memberships is derived from the validity of the direct ones. + # + def can_be_invalidated? + self.direct? + end + end + + concerning :ValidityCheck do + # This method checks whether the membership is valid at the given time. + # + # This is not to be confused with ActiveRecord's `valid` method, which checks whether the + # record matches the requirements to store it in the database. + # + # The following examples are equivalent: + # + # membership.currently_valid? + # membership.valid_at? Time.zone.now + # + def valid_at?(time) + (self.valid_from == nil || self.valid_from <= time) && (self.valid_to == nil || self.valid_to >= time) + end + + # This method checks whether the present time lies within the validity range + # of the membership. + # + def currently_valid? + valid_at?(Time.zone.now) + end + end + + concerning :Localization do + def valid_from_localized_date + self.valid_from ? I18n.localize(self.valid_from.try(:to_date)) : "" + end + def valid_from_localized_date=(new_date) + self.valid_from = new_date.to_datetime + valid_from_will_change! + end + + def set_valid_from_to_now(force = false) + self.valid_from ||= Time.zone.now if self.new_record? or force + return self + end + + def valid_to_localized_date + self.valid_to ? I18n.localize(self.valid_to.try(:to_date)) : "" + end + def valid_to_localized_date=(new_date) + if new_date == "-" + self.valid_to = nil + else + self.valid_to = new_date.to_datetime + end + valid_to_will_change! + end + end + +end \ No newline at end of file diff --git a/app/models/concerns/membership_collection_validity_range.rb b/app/models/concerns/membership_collection_validity_range.rb new file mode 100644 index 000000000..5c525e2ea --- /dev/null +++ b/app/models/concerns/membership_collection_validity_range.rb @@ -0,0 +1,164 @@ +# +# In this project, user group memberships do not neccessarily last forever. +# They can begin at some time and end at some time. This is expressed by the +# ValidityRange of a membership. +# +# +# ## Examples +# +# membership.valid_from # => time +# membership.valid_to # => time +# membership.invalidate +# +# +# ## Scopes +# +# The same functionality can be described from tho different perspectives: +# +# From the "validity perspective", a membership can be currently valid or +# invalid. One can filter memberships by their validity status. +# +# From the "time perspective", there are current memberships and past +# memberships. +# +# These perspectives are linked by the fact that "past memberships" are just +# memberships that are currently invalid but have been valid in the past. +# +# Validity Perspective: +# +# Membership.valid +# Membership.invalid +# Membership.with_invalid +# +# Time Perspective: +# +# Membership.now +# Membership.past +# Membership.now_and_past +# Membership.now_and_in_the_past +# Membership.at_time(time) +# +# Default Scope: +# +# By default, the `valid` scope is applied, i.e. only memberships are +# found that are valid at present time. To override this scope, use the +# either `with_invalid` scope. +# +# +# ## Caveats +# +# * There is only one `valid_from` and one `valid_to` time per object. +# Therefore, you can't keep track of first invalidating an object and +# later re-validating it. Re-validating an object loses the information +# of first invalidating it. +# +# Therefore, when a user leaves and re-joins a group, this is represented +# by two separate Membership objects. +# +# * Currently, the future is not handled (`Article.future` and +# `article.invalidate at: 1.hour.from.now` do not work.) But this is +# planned to be implemented in the future. +# +# * Some functionality has been extracted out into the temporal_scopes gem +# in order to test the scopes easily. But, this code has been abandoned +# since the Rails-4 migration took more time. +# => https://github.com/fiedl/temporal_scopes/blob/master/lib/temporal_scopes/has_temporal_scopes.rb +# +# * Rails 5 supports an `.or(...)` syntax: +# https://github.com/rails/rails/pull/16052 +# TODO: Refactor the queries in the scopes when migrating to Rails 5. +# +concern :MembershipCollectionValidityRange do + + concerning :ValidityPerspective do + def valid + @valid = true + return self + end + + def invalid + @invalid = true + return self + end + + def with_invalid + @with_invalid = true + return self + end + end + + concerning :TimePerspective do + def now + @now = true + return self + end + + def past + @past = true + return self + end + + def in_the_past + @past = true + return self + end + + def with_past + @with_past = true + return self + end + + def now_and_past + @now_and_in_the_past = true + return self + end + + def now_and_in_the_past + @now_and_in_the_past = true + return self + end + + def at_time(time) + @at_time = time + return self + end + + def this_year + @this_year = true + return self + end + + def started_after(time) + @started_after = time + return self + end + end + + private + + def dag_links_for(attrs = {}) + user = attrs[:user]; group = attrs[:group] + links = DagLink.where(ancestor_type: 'Group', descendant_type: 'User', direct: true) + links = links.where(descendant_id: user.id) if user + links = links.where(ancestor_id: group.id) if group + + # Validity Perspective + # + links = links.valid if @valid + links = links.invalid if @invalid + links = links.with_invalid if @with_invalid + + # Time Perspective + # + links = links.now if @now + links = links.past if @past + links = links.with_past if @with_past + links = links.now_and_in_the_past if @now_and_in_the_past + links = links.at_time(@at_time) if @at_time + links = links.this_year if @this_year + links = links.started_after(@started_after) if @started_after + + return links + end + +end \ No newline at end of file diff --git a/app/models/concerns/membership_validity_range.rb b/app/models/concerns/membership_validity_range.rb new file mode 100644 index 000000000..0c87e7a2b --- /dev/null +++ b/app/models/concerns/membership_validity_range.rb @@ -0,0 +1,39 @@ +# This handles the methods that can be used on memberships +# concerning validity ranges, for example, invalidating a membership. +# +# The finder methods can be found in MembershipCollectionValidityRange. +# +concern :MembershipValidityRange do + + concerning :Invalidation do + # This method ends the membership, i.e. sets the end of the validity range + # to the given time. + # + # The following examples are equivalent: + # + # membership.make_invalid + # membership.make_invalid at: Time.zone.now + # membership.make_invalid Time.zone.now + # membership.invalidate # => membership + # + def make_invalid(time = Time.zone.now) + dag_link.try(:make_invalid, time) + return self + end + + # This is just an alias for `make_invalid`. + # + def invalidate(time = Time.zone.now) + self.make_invalid(time) + end + + # This method determines whether the membership can be invalidated. + # Direct memberships can be invalidated, whereas indirect memberships cannot. + # The validity of indirect memberships is derived from the validity of the direct ones. + # + def can_be_invalidated? + self.direct? + end + end + +end \ No newline at end of file diff --git a/app/models/dag_link.rb b/app/models/dag_link.rb index ff4b28419..d62cb050b 100644 --- a/app/models/dag_link.rb +++ b/app/models/dag_link.rb @@ -18,6 +18,7 @@ class DagLink < ActiveRecord::Base before_destroy :delete_cache include DagLinkRepair + include DagLinkValidityRange def fill_cache valid_from diff --git a/app/models/membership.rb b/app/models/membership.rb index ff270bad4..3f9700864 100644 --- a/app/models/membership.rb +++ b/app/models/membership.rb @@ -16,6 +16,8 @@ class Membership attr_accessor :user, :group, :valid_from, :valid_to + include MembershipValidityRange + def initialize(attrs = {}) @user = attrs[:user] @group = attrs[:group] @@ -40,4 +42,46 @@ def ==(other_membership) alias_method :eql?, :== + def direct? + dag_link ? true : false + end + + concerning :Persistence do + def save + write_attributes_to_dag_link + dag_link.save + end + + def save! + raise 'Cannot save! Indirect memberships are non-persistent objects.' unless direct? + write_attributes_to_dag_link + dag_link.save! + end + + def write_attributes_to_dag_link + dag_link.valid_from = @valid_from + dag_link.valid_to = @valid_to + end + + def reload + @dag_link = nil + @valid_from = dag_link.valid_from + @valid_to = dag_link.valid_to + return self + end + + # Direct memberships are stored as DagLinks in the database. + # This is, because we've used the acts_as_dag gem earlier: + # https://github.com/resgraph/acts-as-dag + # + # In contrast to the gem, we do not store indirect links + # in the database anymore, since this makes write operations + # too expensive for large graphs. + # + def dag_link + @dag_link ||= DagLink.where(ancestor_type: 'Group', descendant_type: 'User', direct: true, + ancestor_id: group.id, descendant_id: user.id).first + end + end + end \ No newline at end of file diff --git a/app/models/membership_collection.rb b/app/models/membership_collection.rb index c61656833..0870b4f86 100644 --- a/app/models/membership_collection.rb +++ b/app/models/membership_collection.rb @@ -1,4 +1,6 @@ class MembershipCollection + + include MembershipCollectionValidityRange def where(constraints) @user = constraints[:user] @@ -29,14 +31,6 @@ def to_a private - def dag_links_for(attrs = {}) - user = attrs[:user]; group = attrs[:group] - links = DagLink.where(ancestor_type: 'Group', descendant_type: 'User', direct: true) - links = links.where(descendant_id: user.id) if user - links = links.where(ancestor_id: group.id) if group - return links - end - def dag_links dag_links_for user: @user, group: @group end @@ -64,11 +58,12 @@ def find_all_memberships_by_group end def find_all_memberships_by_user_and_group - find_all_direct_memberships + @group.connected_descendant_groups.collect do |descendant_group| - dag_links_for(group: descendant_group, user: @user).collect do |direct_link| - Membership.new(user: direct_link.descendant, group: descendant_group) - end - end.flatten + find_all_direct_memberships + + if @group.connected_descendant_groups.select { |descendant_group| dag_links_for(group: descendant_group, user: @user).count > 0 }.count > 0 + [ Membership.new(user: @user, group: @group) ] + else + [] + end end end \ No newline at end of file diff --git a/app/models/user_group_membership_mixins/validity_range.rb b/app/models/user_group_membership_mixins/validity_range.rb index 4460ff28a..a3d3b13eb 100644 --- a/app/models/user_group_membership_mixins/validity_range.rb +++ b/app/models/user_group_membership_mixins/validity_range.rb @@ -66,136 +66,12 @@ # TODO: Refactor the queries in the scopes when migrating to Rails 5. # module UserGroupMembershipMixins::ValidityRange - extend ActiveSupport::Concern - - included do - attr_accessible :valid_from, :valid_to, :valid_from_localized_date, :valid_to_localized_date - before_validation :set_valid_from_to_now - - default_scope { valid } - - # Validity Perspective - # TODO: Allow :valid to include memberships that BECOME valid in the future. - scope :valid, -> { where("valid_from IS NULL OR valid_from <= ?", Time.zone.now).where("valid_to IS NULL OR valid_to >= ?", Time.zone.now) } - scope :invalid, -> { with_invalid.where("valid_to < ?", Time.zone.now) } - # scope :with_invalid # This is defined as method below due to some issues. - scope :only_valid, -> { valid } - scope :only_invalid, -> { invalid } - - # Time Perspective - scope :now, -> { valid } - scope :past, -> { invalid } - scope :in_the_past, -> { invalid } - scope :with_past, -> { with_invalid } - scope :now_and_past, -> { with_invalid } - scope :now_and_in_the_past, -> { with_invalid } - scope :at_time, -> (time) { with_past.where("valid_from IS NULL OR valid_from <= ?", time).where("valid_to IS NULL OR valid_to >= ?", time) } - scope :this_year, -> { with_invalid.where("valid_from >= ?", "#{Time.zone.now.year}-01-01 00:00:00") } - scope :started_after, -> (time) { where('NOT valid_from IS NULL').where("valid_from >= ?", time) } - end - module ClassMethods - # This scope widens the query such that also memberships that are not valid - # at the present time are returned. - # - # Have a look at `rewhere`: - # https://github.com/rails/rails/commit/f950b2699f97749ef706c6939a84dfc85f0b05f2#diff-bf6dd6226db3aab589916f09236881c7R562 - # - # But `rewhere` is not enough. We need more filtering: - # https://github.com/fiedl/temporal_scopes/blob/master/lib/temporal_scopes/has_temporal_scopes.rb - # - # TODO: Check if this still needs the extra filter when migrating to Rails 5. - # - def with_invalid - relation = unscope(where: [:valid_from, :valid_to]) - relation.where_values.delete_if { |query| query.to_s.include?("valid_from") || query.to_s.include?("valid_to") } - relation - end - end + # + # This has been moved to DagLinkValidityRange. + # - concerning :Invalidation do - # This method ends the membership, i.e. sets the end of the validity range - # to the given time. - # - # The following examples are equivalent (despite the return value): - # - # membership.make_invalid - # membership.make_invalid at: Time.zone.now - # membership.make_invalid Time.zone.now - # membership.invalidate # => membership - # membership.update_attribute :valid_to, Time.zone.now # => true - # - def make_invalid(time = Time.zone.now) - time = time[:at] if time.kind_of?(Hash) && time[:at] - self.update_attribute(:valid_to, time) - return self - end - - # This is just an alias for `make_invalid`. - # - def invalidate(time = Time.zone.now) - self.make_invalid(time) - end - - # This method determines whether the membership can be invalidated. - # Direct memberships can be invalidated, whereas indirect memberships cannot. - # The validity of indirect memberships is derived from the validity of the direct ones. - # - def can_be_invalidated? - self.direct? - end - end - - concerning :ValidityCheck do - # This method checks whether the membership is valid at the given time. - # - # This is not to be confused with ActiveRecord's `valid` method, which checks whether the - # record matches the requirements to store it in the database. - # - # The following examples are equivalent: - # - # membership.currently_valid? - # membership.valid_at? Time.zone.now - # - def valid_at?(time) - (self.valid_from == nil || self.valid_from <= time) && (self.valid_to == nil || self.valid_to >= time) - end - - # This method checks whether the present time lies within the validity range - # of the membership. - # - def currently_valid? - valid_at?(Time.zone.now) - end - end - - concerning :Localization do - def valid_from_localized_date - self.valid_from ? I18n.localize(self.valid_from.try(:to_date)) : "" - end - def valid_from_localized_date=(new_date) - self.valid_from = new_date.to_datetime - valid_from_will_change! - end - - def set_valid_from_to_now(force = false) - self.valid_from ||= Time.zone.now if self.new_record? or force - return self - end - - def valid_to_localized_date - self.valid_to ? I18n.localize(self.valid_to.try(:to_date)) : "" - end - def valid_to_localized_date=(new_date) - if new_date == "-" - self.valid_to = nil - else - self.valid_to = new_date.to_datetime - end - valid_to_will_change! - end - end end class Array diff --git a/spec/models/concerns/membership_collection_validity_range_spec.rb b/spec/models/concerns/membership_collection_validity_range_spec.rb new file mode 100644 index 000000000..7a94d8ba1 --- /dev/null +++ b/spec/models/concerns/membership_collection_validity_range_spec.rb @@ -0,0 +1,58 @@ +require 'spec_helper' + +describe MembershipCollectionValidityRange do + + # @group1 --- @subgroup1 ------ + # | + # @group2 ------------------ @user1 + # | + # |---------------------- @user2 + # + before do + @group1 = create :group, name: 'group1' + @subgroup1 = @group1.child_groups.create name: 'group2' + @group2 = create :group, name: 'group2' + @user1 = create :user; @subgroup1 << @user1; @group2 << @user1 + @user2 = create :user; @group2 << @user2 + end + + describe "#valid" do + describe "if the dag link between @subgroup1 and @user1 has been invalidated" do + before { @subgroup1.links_as_parent.first.update_attribute :valid_to, 5.minutes.ago } + it "should limit the results to valid links" do + Membership.where(group: @group1).valid.count.should == 0 + Membership.where(group: @subgroup1).valid.count.should == 0 + Membership.where(group: @group2).valid.count.should == 2 + Membership.where(user: @user1).valid.count.should == 1 + Membership.where(user: @user2).valid.count.should == 1 + end + end + end + + describe "#invalid" do + describe "if the dag link between @subgroup1 and @user1 has been invalidated" do + before { @subgroup1.links_as_parent.first.update_attribute :valid_to, 5.minutes.ago } + it "should limit the results to invalid links" do + Membership.where(group: @group1).invalid.count.should == 1 + Membership.where(group: @subgroup1).invalid.count.should == 1 + Membership.where(group: @group2).invalid.count.should == 0 + Membership.where(user: @user1).invalid.count.should == 2 + Membership.where(user: @user2).invalid.count.should == 0 + end + end + end + + describe "#with_invalid" do + describe "if the dag link between @subgroup1 and @user1 has been invalidated" do + before { @subgroup1.links_as_parent.first.update_attribute :valid_to, 5.minutes.ago } + it "should include valid and invalid memberships in the results" do + Membership.where(group: @group1).with_invalid.count.should == 1 + Membership.where(group: @subgroup1).with_invalid.count.should == 1 + Membership.where(group: @group2).with_invalid.count.should == 2 + Membership.where(user: @user1).with_invalid.count.should == 3 + Membership.where(user: @user2).with_invalid.count.should == 1 + end + end + end + +end \ No newline at end of file diff --git a/spec/models/concerns/membership_validity_range_spec.rb b/spec/models/concerns/membership_validity_range_spec.rb new file mode 100644 index 000000000..3114fdc71 --- /dev/null +++ b/spec/models/concerns/membership_validity_range_spec.rb @@ -0,0 +1,64 @@ +require 'spec_helper' + +describe MembershipValidityRange do + + # @group1 --- @subgroup1 ------ + # | + # @group2 ------------------ @user1 + # | + # |---------------------- @user2 + # + before do + @group1 = create :group, name: 'group1' + @subgroup1 = @group1.child_groups.create name: 'group2' + @group2 = create :group, name: 'group2' + @user1 = create :user; @subgroup1 << @user1; @group2 << @user1 + @user2 = create :user; @group2 << @user2 + end + + describe "#invalidate" do + describe "for direct memberships" do + before { @membership = Membership.where(user: @user1, group: @subgroup1).first } + + describe "without argument" do + subject { @membership.invalidate } + + it "sets the valid_to attribute on the DagLink to the current time" do + DagLink.where(ancestor_type: 'Group', descendant_type: 'User', direct: true, + ancestor_id: @subgroup1.id, descendant_id: @user1.id).first + .valid_to.should == nil + subject + DagLink.where(ancestor_type: 'Group', descendant_type: 'User', direct: true, + ancestor_id: @subgroup1.id, descendant_id: @user1.id).first + .valid_to.should > 1.second.ago + end + it "sets the valid_to attribute on the membership to the current time" do + @membership.valid_to.should == nil + subject + @membership.reload.valid_to.should > 1.second.ago + end + end + + describe "with time as argument" do + before { @time = 20.minutes.ago } + subject { @membership.invalidate at: @time } + + it "sets the valid_to attribute on the DagLink to the given time" do + DagLink.where(ancestor_type: 'Group', descendant_type: 'User', direct: true, + ancestor_id: @subgroup1.id, descendant_id: @user1.id).first + .valid_to.should == nil + subject + DagLink.where(ancestor_type: 'Group', descendant_type: 'User', direct: true, + ancestor_id: @subgroup1.id, descendant_id: @user1.id).first + .valid_to.to_i.should == @time.to_i + end + it "sets the valid_to attribute on the membership to the given time" do + @membership.valid_to.should == nil + subject + @membership.reload.valid_to.to_i.should == @time.to_i + end + end + + end + end +end diff --git a/spec/models/membership_spec.rb b/spec/models/membership_spec.rb index b91f16a1a..cacc7d9fc 100644 --- a/spec/models/membership_spec.rb +++ b/spec/models/membership_spec.rb @@ -18,6 +18,23 @@ end describe ".where" do + describe ".where(user: ..., group: ...)" do + describe "for a direct membership" do + subject { Membership.where(user: @user1, group: @group3) } + its(:count) { should == 1 } + its('first.user') { should == @user1 } + its('first.group') { should == @group3 } + its('first.direct?') { should == true } + end + describe "for an indirect membership" do + subject { Membership.where(user: @user1, group: @group2) } + its(:count) { should == 1 } + its('first.user') { should == @user1 } + its('first.group') { should == @group2 } + its('first.direct?') { should == false } + end + end + describe ".where(user: @user1)" do subject { Membership.where(user: @user1) } it { should be_kind_of MembershipCollection } @@ -78,5 +95,33 @@ Membership.where(user: @user1).direct.to_a.should == Membership.direct.where(user: @user1).to_a end end - + + describe "#save!" do + subject { @membership.save! } + + describe "for direct memberships" do + before { @membership = Membership.where(user: @user2, group: @group1).first } + it "should save a changed valid_from and valid_to attributes" do + @membership.valid_from = @time1 = 2.months.ago + @membership.valid_to = @time2 = 1.month.ago + subject + @membership.reload.valid_from.to_i.should == @time1.to_i + @membership.reload.valid_to.to_i.should == @time2.to_i + @membership.dag_link.reload.valid_from.to_i.should == @time1.to_i + @membership.dag_link.reload.valid_to.to_i.should == @time2.to_i + end + end + + describe "for indirect memberships" do + before { @membership = Membership.where(user: @user1, group: @group2).first } + specify "requirements" do + @membership.user.should == @user1 + @membership.group.should == @group2 + end + it "should raise an error, since indirect memberships are non-persistent objects" do + expect { subject }.to raise_error + end + end + end + end \ No newline at end of file From 32f43561f5d8aec7cfa4978faf92cc0853f4b069 Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Mon, 14 Sep 2015 01:33:19 +0200 Subject: [PATCH 06/42] connected groups: officer_parent groups separate group connections. This is because we do not want to consider officers of a group necessarily as members of the group. Trello: https://trello.com/c/wLS0CY6w/807-memberships-man-ist-ein-gruppen-mitgli ed-wenn-man-officer-attendee-oder-contactperson-ist --- .../structureable_connected_groups.rb | 23 ++++++++++--- .../structureable_connected_groups_spec.rb | 32 ++++++++++++++++--- 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/app/models/concerns/structureable_connected_groups.rb b/app/models/concerns/structureable_connected_groups.rb index 5aead58d8..303459325 100644 --- a/app/models/concerns/structureable_connected_groups.rb +++ b/app/models/concerns/structureable_connected_groups.rb @@ -5,13 +5,18 @@ # Example: # # group1 -# |---- group2 --- group3 -# |---- event1 -# |------ attendees_group +# |---- group2 --- group3 -------------- +# |---- event1 | +# | |------ attendees_group ---- user1 +# | +# officers_parent ---- officer_group --- user2 # # In the example, groups 1, 2, and 3 are connected groups. But the attendees_group # is not connected to them, because a non-group object, event1, is in between. # +# Despite `officers_parent` being a group, `user2` is not regarded as +# connected to `group1`, since officers aren't necessarily members of a group. +# # The here implemented mechanism should be independent of the DagLink model, # i.e. can only ask for directly connected objects. Therefore, it relies on caching # rather than indirect graph connections to achieve the neccessary read performance. @@ -23,7 +28,7 @@ def connected_ancestor_groups end def connected_ancestor_group_ids - cached { (parent_group_ids + parent_groups.collect(&:connected_ancestor_group_ids).flatten).uniq } + cached { select_connected_groups(parent_groups).collect { |parent_group| [parent_group.id] + parent_group.connected_ancestor_group_ids }.uniq } end def connected_descendant_groups @@ -31,7 +36,15 @@ def connected_descendant_groups end def connected_descendant_group_ids - cached { (child_group_ids + child_groups.collect(&:connected_descendant_group_ids).flatten).uniq } + cached { select_connected_groups(child_groups).collect { |child_group| [child_group.id] + child_group.connected_descendant_group_ids }.uniq } + end + + private + + def select_connected_groups(groups) + groups.includes(:flags).select do |group| + not group.has_flag? :officers_parent + end end end \ No newline at end of file diff --git a/spec/models/concerns/structureable_connected_groups_spec.rb b/spec/models/concerns/structureable_connected_groups_spec.rb index 2e05b9a9a..2aba17e2d 100644 --- a/spec/models/concerns/structureable_connected_groups_spec.rb +++ b/spec/models/concerns/structureable_connected_groups_spec.rb @@ -7,11 +7,16 @@ # group1 # |---- group2 --- group3 -------------- # |---- event1 | - # |------ attendees_group ---- user1 + # | |------ attendees_group ---- user1 + # | + # officers_parent ---- officer_group --- user2 # # In the example, groups 1, 2, and 3 are connected groups. But the attendees_group # is not connected to them, because a non-group object, event1, is in between. # + # Despite `officers_parent` being a group, `user2` is not regarded as + # connected to `group1`, since officers aren't necessarily members of a group. + # before do @group1 = create :group, name: 'group1' @group2 = @group1.child_groups.create name: 'group2' @@ -19,6 +24,9 @@ @event1 = @group1.child_events.create name: 'event1' @attendees_group = @event1.attendees_group @user1 = create :user; @group3 << @user1; @attendees_group << @user1 + @officers_parent = @group1.officers_parent + @officer_group = @officers_parent.child_groups.create name: 'officer_group' + @user2 = create :user; @officer_group << @user2 end describe "#connected_ancestor_groups" do @@ -47,13 +55,27 @@ it { should include @attendees_group } it { should_not include @event1 } end + + describe "when officer groups are on the path" do + describe "for @user2" do + subject { @user2.connected_ancestor_groups } + it { should include @officer_group } + it { should_not include @group1 } + end + end end describe "#connected_descendant_groups" do - subject { @group1.connected_descendant_groups } - it { should include @group2, @group3 } - it { should_not include @group1 } - it { should_not include @attendees_group } + describe "for @group1" do + subject { @group1.connected_descendant_groups } + it { should include @group2, @group3 } + it { should_not include @group1 } + it { should_not include @attendees_group } + it "should not include officer_parents or officer_groups" do + subject.should_not include @officers_parent + subject.should_not include @officer_group + end + end end end \ No newline at end of file From 823757c35aae71db293e317734b25211a8af85ee Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Mon, 14 Sep 2015 19:09:50 +0200 Subject: [PATCH 07/42] connected groups: fix: the default scope is still needed for UserGroupMemberships. Also, recalculate indirect memberships on creation of the direct ones. This makes all specs pass again. --- app/models/concerns/dag_link_validity_range.rb | 2 -- app/models/user_group_membership.rb | 1 + .../user_group_membership_mixins/validity_range.rb | 6 +++++- .../validity_range_for_indirect_memberships.rb | 12 ++++++++---- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/app/models/concerns/dag_link_validity_range.rb b/app/models/concerns/dag_link_validity_range.rb index 0d47c4438..9a7e5a975 100644 --- a/app/models/concerns/dag_link_validity_range.rb +++ b/app/models/concerns/dag_link_validity_range.rb @@ -74,8 +74,6 @@ attr_accessible :valid_from, :valid_to, :valid_from_localized_date, :valid_to_localized_date before_validation :set_valid_from_to_now - # default_scope { valid } - # Validity Perspective # TODO: Allow :valid to include memberships that BECOME valid in the future. scope :valid, -> { where("valid_from IS NULL OR valid_from <= ?", Time.zone.now).where("valid_to IS NULL OR valid_to >= ?", Time.zone.now) } diff --git a/app/models/user_group_membership.rb b/app/models/user_group_membership.rb index ab29afeae..839fe5bbb 100644 --- a/app/models/user_group_membership.rb +++ b/app/models/user_group_membership.rb @@ -68,6 +68,7 @@ def self.create( params ) # new_membership.set_valid_from_to_now(true) new_membership.save + new_membership.recalculate_indirect_validity_ranges return new_membership end diff --git a/app/models/user_group_membership_mixins/validity_range.rb b/app/models/user_group_membership_mixins/validity_range.rb index a3d3b13eb..c868ca68b 100644 --- a/app/models/user_group_membership_mixins/validity_range.rb +++ b/app/models/user_group_membership_mixins/validity_range.rb @@ -68,8 +68,12 @@ module UserGroupMembershipMixins::ValidityRange extend ActiveSupport::Concern + included do + default_scope { valid } + end + # - # This has been moved to DagLinkValidityRange. + # The rest has been moved to DagLinkValidityRange. # end diff --git a/app/models/user_group_membership_mixins/validity_range_for_indirect_memberships.rb b/app/models/user_group_membership_mixins/validity_range_for_indirect_memberships.rb index 03406264b..c81fa35d7 100644 --- a/app/models/user_group_membership_mixins/validity_range_for_indirect_memberships.rb +++ b/app/models/user_group_membership_mixins/validity_range_for_indirect_memberships.rb @@ -138,12 +138,16 @@ def recalculate_validity_range_from_direct_memberships! end end + def recalculate_indirect_validity_ranges + self.indirect_memberships.each do |indirect_membership| + indirect_membership.recalculate_validity_range_from_direct_memberships + indirect_membership.save + end + end + def recalculate_indirect_validity_ranges_if_needed if self.direct? and @need_to_recalculate_indirect_memberships == true - self.indirect_memberships.each do |indirect_membership| - indirect_membership.recalculate_validity_range_from_direct_memberships - indirect_membership.save - end + recalculate_indirect_validity_ranges end end private :recalculate_indirect_validity_ranges_if_needed From edd3b9a91f1f3875fe7d1967bbb9ab57af0a13ad Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Mon, 14 Sep 2015 23:14:18 +0200 Subject: [PATCH 08/42] connected groups: adding validity check Membership#currently_valid? This also required: 1. When finding memberships using a MembershipCollection, initialize the membership with the validity range. This saves time, since each membership does not need to fetch the validity range separately. 2. Fixing MembershipCollection#find_all_memberships_by_user_and_group to support also multiple memberships, for example when a user is a member in a group, leaves and rejoins later. --- .../concerns/membership_validity_range.rb | 23 ++++++++ app/models/membership_collection.rb | 19 ++++--- .../membership_validity_range_spec.rb | 53 ++++++++++++++++++- 3 files changed, 86 insertions(+), 9 deletions(-) diff --git a/app/models/concerns/membership_validity_range.rb b/app/models/concerns/membership_validity_range.rb index 0c87e7a2b..a4c0af72d 100644 --- a/app/models/concerns/membership_validity_range.rb +++ b/app/models/concerns/membership_validity_range.rb @@ -36,4 +36,27 @@ def can_be_invalidated? end end + concerning :ValidityCheck do + # This method checks whether the membership is valid at the given time. + # + # This is not to be confused with ActiveRecord's `valid` method, which checks whether the + # record matches the requirements to store it in the database. + # + # The following examples are equivalent: + # + # membership.currently_valid? + # membership.valid_at? Time.zone.now + # + def valid_at?(time) + (self.valid_from == nil || self.valid_from <= time) && (self.valid_to == nil || self.valid_to >= time) + end + + # This method checks whether the present time lies within the validity range + # of the membership. + # + def currently_valid? + valid_at?(Time.zone.now) + end + end + end \ No newline at end of file diff --git a/app/models/membership_collection.rb b/app/models/membership_collection.rb index 0870b4f86..e23a78f69 100644 --- a/app/models/membership_collection.rb +++ b/app/models/membership_collection.rb @@ -37,14 +37,16 @@ def dag_links def find_all_direct_memberships dag_links.collect do |direct_link| - Membership.new(user: direct_link.descendant, group: direct_link.ancestor) + Membership.new(user: direct_link.descendant, group: direct_link.ancestor, + valid_from: direct_link.valid_from, valid_to: direct_link.valid_to) end end def find_all_memberships_by_user find_all_direct_memberships.collect do |direct_membership| [ direct_membership ] + direct_membership.group.connected_ancestor_groups.collect do |ancestor_group| - Membership.new(user: direct_membership.user, group: ancestor_group) + Membership.new(user: direct_membership.user, group: ancestor_group, + valid_from: direct_membership.valid_from, valid_to: direct_membership.valid_to) end end.flatten end @@ -52,18 +54,19 @@ def find_all_memberships_by_user def find_all_memberships_by_group find_all_direct_memberships + @group.connected_descendant_groups.collect do |descendant_group| dag_links_for(group: descendant_group).collect do |direct_link| - Membership.new(user: direct_link.descendant, group: descendant_group) + Membership.new(user: direct_link.descendant, group: descendant_group, + valid_from: direct_link.valid_from, valid_to: direct_link.valid_to) end end.flatten end def find_all_memberships_by_user_and_group find_all_direct_memberships + - if @group.connected_descendant_groups.select { |descendant_group| dag_links_for(group: descendant_group, user: @user).count > 0 }.count > 0 - [ Membership.new(user: @user, group: @group) ] - else - [] - end + @group.connected_descendant_groups.collect do |descendant_group| + if link = dag_links_for(group: descendant_group, user: @user).first + Membership.new(user: @user, group: @group, valid_from: link.valid_from, valid_to: link.valid_to) + end + end - [nil] end end \ No newline at end of file diff --git a/spec/models/concerns/membership_validity_range_spec.rb b/spec/models/concerns/membership_validity_range_spec.rb index 3114fdc71..fa4dbd0a4 100644 --- a/spec/models/concerns/membership_validity_range_spec.rb +++ b/spec/models/concerns/membership_validity_range_spec.rb @@ -60,5 +60,56 @@ end end - end + end + + describe "#currently_valid?" do + subject { @membership.currently_valid? } + describe "for direct memberships" do + describe "for a membership without validity range" do + before { @membership = Membership.where(user: @user1, group: @subgroup1).first } + it { should be_true } + end + describe "for a current membership" do + before do + @membership = Membership.where(user: @user1, group: @subgroup1).first + @membership.dag_link.update_attribute :valid_from, 1.month.ago + @membership = Membership.where(user: @user1, group: @subgroup1).first + end + it { should be_true } + end + describe "for a past membership" do + before do + @membership = Membership.where(user: @user1, group: @subgroup1).first + @membership.dag_link.update_attribute :valid_from, 1.month.ago + @membership.dag_link.update_attribute :valid_to, 10.days.ago + @membership = Membership.where(user: @user1, group: @subgroup1).first + end + it { should be_false } + end + end + describe "for indirect memberships" do + describe "for a membership without validity range" do + before { @membership = Membership.where(user: @user1, group: @group1).first } + it { should be_true } + end + describe "for a current membership" do + before do + Membership.where(user: @user1, group: @subgroup1).first.dag_link.update_attribute :valid_from, 1.month.ago + @membership = Membership.where(user: @user1, group: @group1).first + end + it { should be_true } + end + describe "for a past membership" do + before do + Membership.where(user: @user1, group: @subgroup1).first.dag_link.update_attribute :valid_from, 1.month.ago + Membership.where(user: @user1, group: @subgroup1).first.dag_link.update_attribute :valid_to, 10.days.ago + @membership = Membership.where(user: @user1, group: @group1).first + end + it { should be_false } + end + end + end + + end + end From e7777b9233832a4d8ded93e947e7bf7e6079ec02 Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Mon, 14 Sep 2015 23:26:12 +0200 Subject: [PATCH 09/42] connected groups: adding MembershipCollection#uniq MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One has to use `Membership.where(…).uniq` rather than `Membership.where(…).to_a.uniq`, since the Array will compare the objects by their object id rather than their properties. --- app/models/membership_collection.rb | 16 ++++++++++++---- spec/models/membership_spec.rb | 16 ++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/app/models/membership_collection.rb b/app/models/membership_collection.rb index e23a78f69..845332935 100644 --- a/app/models/membership_collection.rb +++ b/app/models/membership_collection.rb @@ -13,18 +13,26 @@ def direct return self end + def uniq + @uniq = true + return self + end + def to_a + memberships = [] if @direct - find_all_direct_memberships + memberships = find_all_direct_memberships else if @user and not @group - find_all_memberships_by_user + memberships = find_all_memberships_by_user elsif @group and not @user - find_all_memberships_by_group + memberships = find_all_memberships_by_group elsif @user and @group - find_all_memberships_by_user_and_group + memberships = find_all_memberships_by_user_and_group end end + memberships = memberships.uniq { |m| [m.group.id, m.user.id, m.valid_from, m.valid_to] } if @uniq + return memberships end delegate :count, :first, :last, to: :to_a diff --git a/spec/models/membership_spec.rb b/spec/models/membership_spec.rb index cacc7d9fc..ca3401b39 100644 --- a/spec/models/membership_spec.rb +++ b/spec/models/membership_spec.rb @@ -96,6 +96,22 @@ end end + describe ".uniq" do + # + # @group1 --- @subgroup1 ------ + # | | + # |------- @subgroup2 --- @user1 + # + before do + @group1 = create :group, name: 'group1' + @subgroup1 = @group1.child_groups.create name: 'subgroup1' + @subgroup2 = @group1.child_groups.create name: 'subgroup2' + @user1 = create :user; @subgroup1 << @user1; @subgroup2 << @user1 + end + subject { Membership.where(user: @user1).uniq } + its(:count) { should == Membership.where(user: @user1).count - 1 } + end + describe "#save!" do subject { @membership.save! } From 3e970f74349304f5a087d78e19b1d42aa3e4145f Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Mon, 14 Sep 2015 23:29:31 +0200 Subject: [PATCH 10/42] connected groups: adding Membership.create This method allows to create new memberships. membership = Membership.create(user: u, group: g) membership = Membership.create(user: u, group: g, valid_from: 1.month_ago, valid_to: 1.day.ago) --- app/models/membership.rb | 23 +++++++++ .../membership_validity_range_spec.rb | 50 +++++++++++++++++++ spec/models/membership_spec.rb | 48 ++++++++++++++++++ 3 files changed, 121 insertions(+) diff --git a/app/models/membership.rb b/app/models/membership.rb index 3f9700864..12dba1b26 100644 --- a/app/models/membership.rb +++ b/app/models/membership.rb @@ -84,4 +84,27 @@ def dag_link end end + # Create a membership of the user `u` in the group `g`. + # + # membership = Membership.create(user: u, group: g) + # membership = Membership.create(user: u, group: g, valid_from: 1.month_ago, valid_to: 1.day.ago) + # + def self.create(params) + user = params[:user] + user ||= User.find params[:user_id] if params[:user_id] + user ||= User.find_by_title params[:user_title] if params[:user_title] + raise "Could not create Membership without user." unless user + + group = params[ :group ] + group ||= Group.find params[:group_id] if params[:group_id] + raise "Could not create Membership without group." unless group + + new_dag_link = DagLink.create!(ancestor_id: group.id, ancestor_type: 'Group', + descendant_id: user.id, descendant_type: 'User', + valid_from: params[:valid_from] || Time.zone.now, + valid_to: params[:valid_to]) + + Membership.new(user: user, group: group, + valid_from: new_dag_link.valid_from, valid_to: new_dag_link.valid_to) + end end \ No newline at end of file diff --git a/spec/models/concerns/membership_validity_range_spec.rb b/spec/models/concerns/membership_validity_range_spec.rb index fa4dbd0a4..3aa551582 100644 --- a/spec/models/concerns/membership_validity_range_spec.rb +++ b/spec/models/concerns/membership_validity_range_spec.rb @@ -110,6 +110,56 @@ end end + describe "#where" do + # + # @group1 --- @subgroup1 @user1 + # | + # |------- @subgroup2 + # + before do + @group1 = create :group, name: 'group1' + @subgroup1 = @group1.child_groups.create name: 'subgroup1' + @subgroup2 = @group1.child_groups.create name: 'subgroup2' + @user1 = create :user + end + subject { Membership.where(user: @user1, group: @group1) } + + describe "for user and group" do + before { @t1 = 2.years.ago; @t2 = 1.year.ago; @t3 = 1.month.ago; @t4 = nil } + describe "if the user has multiple direct memberships in that group" do + before do + @membership1 = Membership.create group: @group1, user: @user1, valid_from: @t1, valid_to: @t2 + @membership2 = Membership.create group: @group1, user: @user1, valid_from: @t3 + end + its(:count) { should == 2 } + specify "the memberships should have the correct validity range" do + subject.to_a.collect { |membership| membership.valid_from.try(:to_i) }.should include @t1.to_i, @t3.to_i + subject.to_a.collect { |membership| membership.valid_to.try(:to_i) }.should include @t2.to_i, @t4 + end + end + describe "if the user has multiple indirect memberships in that group" do + before do + @membership1 = Membership.create group: @subgroup1, user: @user1, valid_from: @t1, valid_to: @t2 + @membership2 = Membership.create group: @subgroup2, user: @user1, valid_from: @t3 + end + its(:count) { should == 2 } + specify "the memberships should have the correct validity range" do + subject.to_a.collect { |membership| membership.valid_from.try(:to_i) }.should include @t1.to_i, @t3.to_i + subject.to_a.collect { |membership| membership.valid_to.try(:to_i) }.should include @t2.to_i, @t4 + end + end + describe "if the user has direct and indirect memberships in that group" do + before do + @membership1 = Membership.create group: @group1, user: @user1, valid_from: @t1, valid_to: @t2 + @membership2 = Membership.create group: @subgroup1, user: @user1, valid_from: @t3 + end + its(:count) { should == 2 } + specify "the memberships should have the correct validity range" do + subject.to_a.collect { |membership| membership.valid_from.try(:to_i) }.should include @t1.to_i, @t3.to_i + subject.to_a.collect { |membership| membership.valid_to.try(:to_i) }.should include @t2.to_i, @t4 + end + end + end end end diff --git a/spec/models/membership_spec.rb b/spec/models/membership_spec.rb index ca3401b39..c1f1e8c24 100644 --- a/spec/models/membership_spec.rb +++ b/spec/models/membership_spec.rb @@ -140,4 +140,52 @@ end end + describe ".create" do + # + # @group1 --- @subgroup1 @user1 + # + before do + @group1 = create :group, name: 'group1' + @subgroup1 = @group1.child_groups.create name: 'subgroup1' + @user1 = create :user + end + describe "creating a direct membership" do + subject { Membership.create group: @group1, user: @user1 } + it "should create the corresponding dag link" do + @user1.links_as_child.count.should == 0 + subject + @user1.links_as_child.count.should == 1 + @user1.links_as_child.first.ancestor.should == @group1 + end + it { should be_kind_of Membership } + its(:group) { should == @group1 } + its(:user) { should == @user1 } + + describe "if the membership already exists indirectly" do + before { Membership.create group: @subgroup1, user: @user1 } + it "should create the corresponding dag link" do + @user1.links_as_child.count.should == 1 + subject + @user1.links_as_child.count.should == 2 + @user1.links_as_child.last.ancestor.should == @group1 + end + it { should be_kind_of Membership } + its(:group) { should == @group1 } + its(:user) { should == @user1 } + end + end + + describe "creating a direct membership with validity range" do + before { @time1 = 1.month.ago; @time2 = 10.days.ago } + subject { Membership.create group: @group1, user: @user1, valid_from: @time1, valid_to: @time2 } + it { should be_kind_of Membership } + its(:group) { should == @group1 } + its(:user) { should == @user1 } + its('valid_from.to_i') { should == @time1.to_i } + its('valid_to.to_i') { should == @time2.to_i } + its('dag_link.valid_from.to_i') { should == @time1.to_i } + its('dag_link.valid_to.to_i') { should == @time2.to_i } + end + end + end \ No newline at end of file From d5e19115eae4e51918554703833feff39476400d Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Mon, 14 Sep 2015 23:50:00 +0200 Subject: [PATCH 11/42] connected groups: Adding methods to Membership for validity range localization. We need this when modifying the validity range through forms. --- .../membership_validity_range_localization.rb | 26 ++++ app/models/membership.rb | 1 + ...ership_validity_range_localization_spec.rb | 112 ++++++++++++++++++ 3 files changed, 139 insertions(+) create mode 100644 app/models/concerns/membership_validity_range_localization.rb create mode 100644 spec/models/concerns/membership_validity_range_localization_spec.rb diff --git a/app/models/concerns/membership_validity_range_localization.rb b/app/models/concerns/membership_validity_range_localization.rb new file mode 100644 index 000000000..ad875da3b --- /dev/null +++ b/app/models/concerns/membership_validity_range_localization.rb @@ -0,0 +1,26 @@ +concern :MembershipValidityRangeLocalization do + + def valid_from_localized_date + self.valid_from ? I18n.localize(self.valid_from.try(:to_date)) : "" + end + def valid_from_localized_date=(new_date) + self.valid_from = new_date.to_datetime + end + + def set_valid_from_to_now(force = false) + self.valid_from ||= Time.zone.now if self.new_record? or force + return self + end + + def valid_to_localized_date + self.valid_to ? I18n.localize(self.valid_to.try(:to_date)) : "" + end + def valid_to_localized_date=(new_date) + if new_date == "-" + self.valid_to = nil + else + self.valid_to = new_date.to_datetime + end + end + +end \ No newline at end of file diff --git a/app/models/membership.rb b/app/models/membership.rb index 12dba1b26..22b81eaf3 100644 --- a/app/models/membership.rb +++ b/app/models/membership.rb @@ -17,6 +17,7 @@ class Membership attr_accessor :user, :group, :valid_from, :valid_to include MembershipValidityRange + include MembershipValidityRangeLocalization def initialize(attrs = {}) @user = attrs[:user] diff --git a/spec/models/concerns/membership_validity_range_localization_spec.rb b/spec/models/concerns/membership_validity_range_localization_spec.rb new file mode 100644 index 000000000..ebd450e5c --- /dev/null +++ b/spec/models/concerns/membership_validity_range_localization_spec.rb @@ -0,0 +1,112 @@ +require 'spec_helper' + +describe MembershipValidityRangeLocalization do + + # + # @group1 --- @user1 + # + before do + @group1 = create :group, name: 'group1' + @user1 = create :user + @membership = Membership.create user: @user1, group: @group1 + end + + describe "#valid_from_localized_date" do + subject { @membership.valid_from_localized_date } + describe "if no valid_from given" do + before { @membership.valid_from = nil } + it { should == "" } + end + describe "if a datetime given" do + before do + @time = "1.1.2013 12:30 UTC".to_datetime + @membership.valid_from = @time + end + it { should == "01.01.2013" } + end + end + + describe "#valid_from_localized_date=" do + describe "setting a date string" do + subject { @membership.valid_from_localized_date = "1.1.2013" } + it "should set the correct date" do + subject + @membership.valid_from.to_date.should == "1.1.2013".to_date + end + it "should set the date persistently when saved" do + subject + @membership.save + @membership.reload.valid_from.to_date.should == "1.1.2013".to_date + end + end + describe "setting an empty string" do + subject { @membership.valid_from_localized_date = "" } + it "should set valid_from to nil" do + subject + @membership.valid_from.should == nil + end + it "should set the date persistently when saved" do + subject + @membership.save + @membership.reload.valid_from.should == nil + end + end + describe "setting an invalid date" do + subject { @membership.valid_from_localized_date = "FOO BAR" } + it "should raise an error" do + expect { subject }.to raise_error + end + end + end + + + describe "#valid_to_localized_date" do + subject { @membership.valid_to_localized_date } + describe "if no valid_to given" do + before { @membership.valid_to = nil } + it { should == "" } + end + describe "if a datetime given" do + before do + @time = "1.1.2013 12:30 UTC".to_datetime + @membership.valid_to = @time + end + it { should == "01.01.2013" } + end + end + + describe "#valid_to_localized_date=" do + describe "setting a date string" do + subject { @membership.valid_to_localized_date = "1.1.2013" } + it "should set the correct date" do + subject + @membership.valid_to.to_date.should == "1.1.2013".to_date + end + it "should set the date persistently when saved" do + subject + @membership.save + @membership.reload.valid_to.to_date.should == "1.1.2013".to_date + end + end + describe "setting an empty string" do + subject { @membership.valid_to_localized_date = "" } + it "should set valid_to to nil" do + subject + @membership.valid_to.should == nil + end + it "should set the date persistently when saved" do + subject + @membership.save + @membership.reload.valid_to.should == nil + end + end + describe "setting an invalid date" do + subject { @membership.valid_to_localized_date = "FOO BAR" } + it "should raise an error" do + expect { subject }.to raise_error + end + end + end + + +end \ No newline at end of file From 5fc2958d7b89f1879d3a20c0032c4ec6c3dcf3c1 Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Tue, 15 Sep 2015 00:08:43 +0200 Subject: [PATCH 12/42] connected groups: addimg Membership#id and Membership.find(id). This only works for direct memberships and directly refers to the id of the corresponding DagLink. --- app/models/membership.rb | 23 +++++++++++++++++++---- spec/models/membership_spec.rb | 20 ++++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/app/models/membership.rb b/app/models/membership.rb index 22b81eaf3..eb4543da9 100644 --- a/app/models/membership.rb +++ b/app/models/membership.rb @@ -20,16 +20,28 @@ class Membership include MembershipValidityRangeLocalization def initialize(attrs = {}) - @user = attrs[:user] - @group = attrs[:group] - @valid_from = attrs[:valid_from] - @valid_to = attrs[:valid_to] + @dag_link = attrs[:dag_link] + @user = @dag_link.try(:descendant) || attrs[:user] + @group = @dag_link.try(:ancestor) || attrs[:group] + @valid_from = @dag_link.try(:valid_from) || attrs[:valid_from] + @valid_to = @dag_link.try(:valid_to) || attrs[:valid_to] end def self.where(constraints = {}) MembershipCollection.new.where(constraints) end + # This represents a single direct membership, which is identified by the id of the + # dag link that connects the user and the group of the membership. + # + def self.find(id) + if link = DagLink.find(id) + Membership.new(dag_link: link) + else + nil + end + end + def self.direct MembershipCollection.new.direct end @@ -48,6 +60,9 @@ def direct? end concerning :Persistence do + def id + dag_link.try(:id) + end def save write_attributes_to_dag_link dag_link.save diff --git a/spec/models/membership_spec.rb b/spec/models/membership_spec.rb index c1f1e8c24..54213984b 100644 --- a/spec/models/membership_spec.rb +++ b/spec/models/membership_spec.rb @@ -86,6 +86,26 @@ end end + describe ".find" do + subject { Membership.find(@dag_link_id) } + # + # group2 --- group3 --- user1 + # + describe "for a direct membership" do + before { @dag_link_id = Membership.where(user: @user1, group: @group3).first.dag_link.id } + it { should be_kind_of Membership } + its(:user) { should == @user1 } + its(:group) { should == @group3 } + its(:direct?) { should be_true } + end + describe "for a non-existent dag link" do + before { @dag_link_id = DagLink.pluck(:id).max + 5 } + it "should raise an error" do + expect { subject }.to raise_error + end + end + end + describe ".direct" do it "reduces the scope to direct memberships" do Membership.where(user: @user1).direct.count.should == 1 From d3d6d60d512d1299dfd45484270f0878b9a6af60 Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Tue, 15 Sep 2015 09:24:15 +0200 Subject: [PATCH 13/42] =?UTF-8?q?connected=20groups:=20fixing=20Membership?= =?UTF-8?q?#where(group:=20=E2=80=A6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There has been an issue where the wrong group has been associated with the membership. --- app/models/membership_collection.rb | 17 +++++------ spec/models/membership_spec.rb | 45 +++++++++++++++++------------ 2 files changed, 35 insertions(+), 27 deletions(-) diff --git a/app/models/membership_collection.rb b/app/models/membership_collection.rb index 845332935..e65832ece 100644 --- a/app/models/membership_collection.rb +++ b/app/models/membership_collection.rb @@ -53,28 +53,27 @@ def find_all_direct_memberships def find_all_memberships_by_user find_all_direct_memberships.collect do |direct_membership| [ direct_membership ] + direct_membership.group.connected_ancestor_groups.collect do |ancestor_group| - Membership.new(user: direct_membership.user, group: ancestor_group, + Membership.new(user: @user, group: ancestor_group, valid_from: direct_membership.valid_from, valid_to: direct_membership.valid_to) end end.flatten end def find_all_memberships_by_group - find_all_direct_memberships + @group.connected_descendant_groups.collect do |descendant_group| - dag_links_for(group: descendant_group).collect do |direct_link| - Membership.new(user: direct_link.descendant, group: descendant_group, - valid_from: direct_link.valid_from, valid_to: direct_link.valid_to) - end - end.flatten + find_all_direct_memberships + + dag_links_for(group_ids: @group.connected_descendant_group_ids).collect do |direct_link| + Membership.new(user: direct_link.descendant, group: @group, + valid_from: direct_link.valid_from, valid_to: direct_link.valid_to) + end end def find_all_memberships_by_user_and_group find_all_direct_memberships + @group.connected_descendant_groups.collect do |descendant_group| - if link = dag_links_for(group: descendant_group, user: @user).first + dag_links_for(group: descendant_group, user: @user).collect do |link| Membership.new(user: @user, group: @group, valid_from: link.valid_from, valid_to: link.valid_to) end - end - [nil] + end.flatten - [nil] end end \ No newline at end of file diff --git a/spec/models/membership_spec.rb b/spec/models/membership_spec.rb index 54213984b..aca3fd74e 100644 --- a/spec/models/membership_spec.rb +++ b/spec/models/membership_spec.rb @@ -43,25 +43,34 @@ its(:count) { should == 2 } end - describe "for groups that have direct members" do - describe ".where(group: @group3)" do - subject { Membership.where(group: @group3) } - it { should be_kind_of MembershipCollection } - its(:to_a) { should be_kind_of Array } - its(:first) { should be_kind_of Membership } - its(:count) { should == 1 } - its('direct.count') { should == 1 } + describe ".where(group: ...)" do + # + # group2 --- group3 --- user1 + # + describe "for groups that have direct members" do + describe ".where(group: @group3)" do + subject { Membership.where(group: @group3) } + it { should be_kind_of MembershipCollection } + its(:to_a) { should be_kind_of Array } + its(:first) { should be_kind_of Membership } + its(:count) { should == 1 } + its('direct.count') { should == 1 } + its('first.user') { should == @user1 } + its('first.group') { should == @group3 } + end end - end - - describe "for groups that have indirect members" do - describe ".where(group: @group2)" do - subject { Membership.where(group: @group2) } - it { should be_kind_of MembershipCollection } - its(:to_a) { should be_kind_of Array } - its(:first) { should be_kind_of Membership } - its(:count) { should == 1 } - its('direct.count') { should == 0 } + + describe "for groups that have indirect members" do + describe ".where(group: @group2)" do + subject { Membership.where(group: @group2) } + it { should be_kind_of MembershipCollection } + its(:to_a) { should be_kind_of Array } + its(:first) { should be_kind_of Membership } + its(:count) { should == 1 } + its('direct.count') { should == 0 } + its('first.user') { should == @user1 } + its('first.group') { should == @group2 } + end end end From c7f33ca745ea1fed7596459e5dd52ecd80019b85 Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Tue, 15 Sep 2015 10:10:45 +0200 Subject: [PATCH 14/42] connected groups: N+1 optimization and query count spec --- .../membership_collection_validity_range.rb | 11 +++-- .../structureable_connected_groups.rb | 6 +-- spec/models/membership_spec.rb | 23 ++++++++++- spec/spec_helper.rb | 13 ++++++ spec/support/count_queries.rb | 41 +++++++++++++++++++ 5 files changed, 87 insertions(+), 7 deletions(-) create mode 100644 spec/support/count_queries.rb diff --git a/app/models/concerns/membership_collection_validity_range.rb b/app/models/concerns/membership_collection_validity_range.rb index 5c525e2ea..01969981a 100644 --- a/app/models/concerns/membership_collection_validity_range.rb +++ b/app/models/concerns/membership_collection_validity_range.rb @@ -137,10 +137,11 @@ def started_after(time) private def dag_links_for(attrs = {}) - user = attrs[:user]; group = attrs[:group] links = DagLink.where(ancestor_type: 'Group', descendant_type: 'User', direct: true) - links = links.where(descendant_id: user.id) if user - links = links.where(ancestor_id: group.id) if group + links = links.where(descendant_id: attrs[:user].id) if attrs[:user] + links = links.where(ancestor_id: attrs[:group].id) if attrs[:group] + links = links.where(descendant_id: attrs[:user_ids]) if attrs[:user_ids] + links = links.where(ancestor_id: attrs[:group_ids]) if attrs[:group_ids] # Validity Perspective # @@ -158,6 +159,10 @@ def dag_links_for(attrs = {}) links = links.this_year if @this_year links = links.started_after(@started_after) if @started_after + # Include the associated objects to avoid the N+1 problem. + # + links = links.includes(:ancestor, :descendant) + return links end diff --git a/app/models/concerns/structureable_connected_groups.rb b/app/models/concerns/structureable_connected_groups.rb index 303459325..7e9f0eeda 100644 --- a/app/models/concerns/structureable_connected_groups.rb +++ b/app/models/concerns/structureable_connected_groups.rb @@ -28,7 +28,7 @@ def connected_ancestor_groups end def connected_ancestor_group_ids - cached { select_connected_groups(parent_groups).collect { |parent_group| [parent_group.id] + parent_group.connected_ancestor_group_ids }.uniq } + cached { select_connected_groups(parent_groups).collect { |parent_group| [parent_group.id] + parent_group.connected_ancestor_group_ids }.flatten.uniq } end def connected_descendant_groups @@ -36,13 +36,13 @@ def connected_descendant_groups end def connected_descendant_group_ids - cached { select_connected_groups(child_groups).collect { |child_group| [child_group.id] + child_group.connected_descendant_group_ids }.uniq } + cached { select_connected_groups(child_groups).collect { |child_group| [child_group.id] + child_group.connected_descendant_group_ids }.flatten.uniq } end private def select_connected_groups(groups) - groups.includes(:flags).select do |group| + groups.select do |group| not group.has_flag? :officers_parent end end diff --git a/spec/models/membership_spec.rb b/spec/models/membership_spec.rb index aca3fd74e..2377d4f3f 100644 --- a/spec/models/membership_spec.rb +++ b/spec/models/membership_spec.rb @@ -36,11 +36,29 @@ end describe ".where(user: @user1)" do + # + # page1 -------| + # group4 --- group2 --- group3 --- user1 + # + before { @group4 = @group2.parent_groups.create name: 'group4' } subject { Membership.where(user: @user1) } it { should be_kind_of MembershipCollection } its(:to_a) { should be_kind_of Array } its(:first) { should be_kind_of Membership } - its(:count) { should == 2 } + its(:count) { should == 3 } + it "should not perform too many queries" do + # (User, Group, DagLink, Flag) for the direct links + # (Group, Flag, Flag) for each generation hop, in this case, 3 hops, without caching. + count_queries(13) { subject.to_a }.should <= 13 # scales with number of hops + end + describe "when connected groups are already cached" do + before { @user1.connected_ancestor_groups } + it "should not perform too many queries" do + # (User, Group, DagLink, Flag) for the direct links + # (Group, Flag) for the indirect connected groups + count_queries(6) { subject.to_a }.should <= 6 # does not scale with number of hops + end + end end describe ".where(group: ...)" do @@ -106,6 +124,9 @@ its(:user) { should == @user1 } its(:group) { should == @group3 } its(:direct?) { should be_true } + it "should perform only 4 queries: DagLink, User, Group and Flags" do + count_queries(4) { subject }.should <= 4 + end end describe "for a non-existent dag link" do before { @dag_link_id = DagLink.pluck(:id).max + 5 } diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index febd941c9..7e9878d3b 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -206,6 +206,19 @@ # Call `debug` to enter pry. # config.include Debug + + # This counts the number of queries performed within the block. + # + # Usage: + # + # expected_count = 10 + # count = count_queries(expected_count) do + # SomeModel.first + # end + # + # If the count does not match the expected_count, the queries are printed out. + # + config.include CountQueries # Devise test helper for controller tests config.include Devise::TestHelpers, :type => :controller diff --git a/spec/support/count_queries.rb b/spec/support/count_queries.rb new file mode 100644 index 000000000..ef17797f9 --- /dev/null +++ b/spec/support/count_queries.rb @@ -0,0 +1,41 @@ +require 'colored' +module CountQueries + + # This counts the number of queries performed within the block. + # + # Usage: + # + # c = count_queries do + # SomeModel.first + # end + # + # See also: http://stackoverflow.com/a/22388177/2066546 + # + def count_queries(expected_count = nil, &block) + queries = collect_queries(&block) + + # Output the queries if the count does not match. + if expected_count && queries.count != expected_count + queries.each do |query| + print query.yellow + "\n\n" + end + end + + return queries.count + end + + def collect_queries(&block) + queries = [] + + counter_f = ->(name, started, finished, unique_id, payload) { + unless payload[:name].in? %w[ CACHE SCHEMA ] + queries << payload[:sql] + end + } + + ActiveSupport::Notifications.subscribed(counter_f, "sql.active_record", &block) + + return queries + end + +end \ No newline at end of file From cc70b97e16c06799becfaf63d38d87ca0db5ac26 Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Tue, 15 Sep 2015 10:12:14 +0200 Subject: [PATCH 15/42] connected groups: MembershipPersistence methods, which are needed when memberships are regarded as resources. --- app/models/concerns/membership_persistence.rb | 87 +++++++++++++++++++ app/models/membership.rb | 64 +++++--------- 2 files changed, 111 insertions(+), 40 deletions(-) create mode 100644 app/models/concerns/membership_persistence.rb diff --git a/app/models/concerns/membership_persistence.rb b/app/models/concerns/membership_persistence.rb new file mode 100644 index 000000000..675185c1a --- /dev/null +++ b/app/models/concerns/membership_persistence.rb @@ -0,0 +1,87 @@ +concern :MembershipPersistence do + + # Direct memberships are stored as DagLinks in the database. + # This is, because we've used the acts_as_dag gem earlier: + # https://github.com/resgraph/acts-as-dag + # + # In contrast to the gem, we do not store indirect links + # in the database anymore, since this makes write operations + # too expensive for large graphs. + # + def dag_link + @dag_link ||= DagLink.where(ancestor_type: 'Group', descendant_type: 'User', direct: true, + ancestor_id: group.id, descendant_id: user.id).first + end + + def id + dag_link.try(:id) + end + + def persisted? + dag_link.try(:persisted?) || false + end + + def save + write_attributes_to_dag_link + dag_link.save + end + + def save! + raise 'Cannot save! Indirect memberships are non-persistent objects.' unless direct? + write_attributes_to_dag_link + dag_link.save! + end + + def update_attributes!(attrs = {}) + attrs.each do |key, value| + send("#{key}=", value) + end + save! + end + + def write_attributes_to_dag_link + dag_link.valid_from = @valid_from + dag_link.valid_to = @valid_to + dag_link.ancestor_id = @group.id + dag_link.descendant_id = @user.id + end + + def reload + @dag_link = nil + @valid_from = dag_link.valid_from + @valid_to = dag_link.valid_to + return self + end + + def _read_attribute(key) + send(key) if key.in? [:valid_from, :valid_to] + end + + delegate :destroyed?, :new_record?, to: :dag_link + + def destroyable? + direct? && dag_link.destroyable? + end + + def destroy + (destroyable? && dag_link.try(:destroy)) || raise("could not destroy membership #{id}.") + end + + + class_methods do + def base_class + Membership + end + + def primary_key + :id + end + + def build(params) + group_id = params[:group_id] || params[:group].try(:id) + user_id = params[:user_id] || params[:user].try(:id) + Membership.new(dag_link: DagLink.new(ancestor_type: 'Group', ancestor_id: group_id, descendant_type: 'User', descendant_id: user_id)) + end + end + +end \ No newline at end of file diff --git a/app/models/membership.rb b/app/models/membership.rb index eb4543da9..b68473370 100644 --- a/app/models/membership.rb +++ b/app/models/membership.rb @@ -14,8 +14,12 @@ # class Membership + # http://guides.rubyonrails.org/active_model_basics.html#model + include ActiveModel::Model + attr_accessor :user, :group, :valid_from, :valid_to + include MembershipPersistence include MembershipValidityRange include MembershipValidityRangeLocalization @@ -47,6 +51,7 @@ def self.direct end def ==(other_membership) + other_membership.kind_of? Membership and self.group.id == other_membership.group.id and self.user.id = other_membership.user.id and self.valid_from == other_membership.valid_from and @@ -59,47 +64,25 @@ def direct? dag_link ? true : false end - concerning :Persistence do - def id - dag_link.try(:id) - end - def save - write_attributes_to_dag_link - dag_link.save - end - - def save! - raise 'Cannot save! Indirect memberships are non-persistent objects.' unless direct? - write_attributes_to_dag_link - dag_link.save! - end - - def write_attributes_to_dag_link - dag_link.valid_from = @valid_from - dag_link.valid_to = @valid_to - end - - def reload - @dag_link = nil - @valid_from = dag_link.valid_from - @valid_to = dag_link.valid_to - return self - end - - # Direct memberships are stored as DagLinks in the database. - # This is, because we've used the acts_as_dag gem earlier: - # https://github.com/resgraph/acts-as-dag - # - # In contrast to the gem, we do not store indirect links - # in the database anymore, since this makes write operations - # too expensive for large graphs. - # - def dag_link - @dag_link ||= DagLink.where(ancestor_type: 'Group', descendant_type: 'User', direct: true, - ancestor_id: group.id, descendant_id: user.id).first - end + def to_param + id.to_s end - + + def group_id + group.try(:id) + end + def group_id=(new_group_id) + group = Group.find new_group_id + end + + def user_title + user.try(:title) + end + def user_title=(new_user_title) + user = User.find_by_title new_user_title + end + + # Create a membership of the user `u` in the group `g`. # # membership = Membership.create(user: u, group: g) @@ -123,4 +106,5 @@ def self.create(params) Membership.new(user: user, group: group, valid_from: new_dag_link.valid_from, valid_to: new_dag_link.valid_to) end + end \ No newline at end of file From 9f3e592caadddd5588bc35074485444dc50a7a38 Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Tue, 15 Sep 2015 10:13:18 +0200 Subject: [PATCH 16/42] connected groups: migrating memberships#index to the new mechanism. --- app/controllers/memberships_controller.rb | 85 +++++++++++++++++++ .../user_group_memberships_controller.rb | 18 ---- app/models/group_mixins/memberships.rb | 2 +- .../_memberships_table.html.haml | 4 +- .../index.html.haml | 0 config/routes.rb | 5 +- 6 files changed, 90 insertions(+), 24 deletions(-) create mode 100644 app/controllers/memberships_controller.rb rename app/views/{user_group_memberships => memberships}/_memberships_table.html.haml (91%) rename app/views/{user_group_memberships => memberships}/index.html.haml (100%) diff --git a/app/controllers/memberships_controller.rb b/app/controllers/memberships_controller.rb new file mode 100644 index 000000000..fc56cff7c --- /dev/null +++ b/app/controllers/memberships_controller.rb @@ -0,0 +1,85 @@ +class MembershipsController < ApplicationController + + before_action :find_membership, except: [:index, :create] + authorize_resource + + respond_to :json, :html + + def index + if params[:user_id] + @object = @user = User.find(params[:user_id]) + authorize! :manage, @user + + @memberships = Membership.where(user: @user).now_and_in_the_past.direct.to_a + elsif params[:group_id] + @object = @group = Group.find(params[:group_id]) + authorize! :manage, @group + + @memberships = Membership.where(group: @group).now_and_in_the_past.direct.to_a + end + + set_current_navable @object + set_current_title "#{t(:memberships)}: #{@object.title}" + set_current_activity :is_managing_member_lists, @object + end + + def update + if @membership.update_attributes!(membership_params) + respond_to do |format| + format.json do + respond_with @membership + end + end + end + end + + def destroy + @membership.try(:destroy) && head(:no_content) + end + + def create + if membership_params[:user_title].present? + @user_id = User.find_by_title(membership_params[:user_title]).id + @group = Group.find membership_params[:group_id] + begin + @membership = UserGroupMembership.create(membership_params.merge({user_id: @user_id})) + @membership.valid_from = Date.new(membership_params["valid_from(1i)"].to_i, + membership_params["valid_from(2i)"].to_i, + membership_params["valid_from(3i)"].to_i) + @membership.save! + redirect_to group_members_path(@membership.group), change: 'members' + rescue => error + redirect_to group_members_path(@group), change: 'members', alert: "#{t(:adding_member_did_not_work)} #{error.message}" + end + else + head :no_content + end + end + + + private + + def membership_params + if can? :manage, @membership + params.require(:membership).permit(:valid_to, :valid_from, :user_title, :user_id, :group_id, :id, + :valid_from_localized_date, :valid_to_localized_date, + :needs_review, :ancestor_id, :ancestor_type, :descendant_id, :descendant_type) + elsif can? :update, @membership + params.require(:membership).permit(:valid_to, :valid_from, :user_title, :user_id, :group_id, :id, + :valid_from_localized_date, :valid_to_localized_date) + end + end + + def find_membership + #if params[:id].present? + @membership = Membership.find params[:id] + #else + # user = User.find params[ :user_id ] if params[ :user_id ] + # group = Group.find params[ :group_id ] if params[ :group_id ] + # if user && group + # @user_group_membership = UserGroupMembership.with_invalid.find_by_user_and_group user, group + # end + #end + end + +end diff --git a/app/controllers/user_group_memberships_controller.rb b/app/controllers/user_group_memberships_controller.rb index 788e3e117..0fdceeb3c 100644 --- a/app/controllers/user_group_memberships_controller.rb +++ b/app/controllers/user_group_memberships_controller.rb @@ -5,24 +5,6 @@ class UserGroupMembershipsController < ApplicationController respond_to :json, :html - def index - if params[:user_id] - @object = @user = User.find(params[:user_id]) - authorize! :manage, @user - - @memberships = UserGroupMembership.now_and_in_the_past.find_all_by_user(@user) - elsif params[:group_id] - @object = @group = Group.find(params[:group_id]) - authorize! :manage, @group - - @memberships = UserGroupMembership.now_and_in_the_past.find_all_by_group(@group) - end - - set_current_navable @object - set_current_title "#{t(:memberships)}: #{@object.title}" - set_current_activity :is_managing_member_lists, @object - end - def create if membership_params[:user_title].present? @user_id = User.find_by_title(membership_params[:user_title]).id diff --git a/app/models/group_mixins/memberships.rb b/app/models/group_mixins/memberships.rb index cdf33e734..9b3409e13 100644 --- a/app/models/group_mixins/memberships.rb +++ b/app/models/group_mixins/memberships.rb @@ -40,7 +40,7 @@ module GroupMixins::Memberships # This method builds a new membership having this group (self) as group associated. # def build_membership - direct_memberships.build(descendant_type: 'User') + Membership.build(group: self) end # This returns the UserGroupMembership object that represents the membership of the diff --git a/app/views/user_group_memberships/_memberships_table.html.haml b/app/views/memberships/_memberships_table.html.haml similarity index 91% rename from app/views/user_group_memberships/_memberships_table.html.haml rename to app/views/memberships/_memberships_table.html.haml index 07e9fa3d2..b20cc64fb 100644 --- a/app/views/user_group_memberships/_memberships_table.html.haml +++ b/app/views/memberships/_memberships_table.html.haml @@ -20,12 +20,11 @@ %th Pfad %th Mitglied seit %th Mitglied bis - %th Direkte Mitgliedschaft %th Löschen %tbody - memberships.each do |membership| %tr{class: ((membership.group && membership.currently_valid?) ? "currently_valid" : "currently_invalid")} - %td.copy-to-clipboard{title: "UserGroupMembership.now_and_in_the_past.find(#{membership.id})"}= membership.id + %td.copy-to-clipboard{title: "Membership.find(#{membership.id})"}= membership.id %td - if @user - if membership.group @@ -62,7 +61,6 @@ = localize(membership.valid_to.to_date) if membership.valid_to - else = localize(membership.read_attribute(:valid_to).to_date) if membership.read_attribute(:valid_to) - %td= membership.direct? ? "direkt".html_safe : "count: #{membership.count}" %td - if membership.destroyable? and can?(:destroy, membership) = remove_button membership, show_only_in_edit_mode: false, confirm: "Dies löscht den Eintrag unwiderbringlich! Dies soll nicht dazu verwendet werden, um eine Mitgliedschaft zu beenden, sondern NUR, WENN es sich um einen FEHLERHAFTEN EINTRAG handelt. Soll der Eintrag wirklich gelöscht werden?" \ No newline at end of file diff --git a/app/views/user_group_memberships/index.html.haml b/app/views/memberships/index.html.haml similarity index 100% rename from app/views/user_group_memberships/index.html.haml rename to app/views/memberships/index.html.haml diff --git a/config/routes.rb b/config/routes.rb index 39441bb9e..753614703 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -44,7 +44,7 @@ get :profile, to: 'profile_fields#index' get :settings, to: 'user_settings#show' put :settings, to: 'user_settings#update' - get :memberships, to: 'user_group_memberships#index' + get :memberships, to: 'memberships#index' get :badges, to: 'user_badges#index' end get :settings, to: 'user_settings#index' @@ -62,7 +62,7 @@ get :officers, to: 'officers#index' get :settings, to: 'group_settings#index' get :mailing_lists, to: 'mailing_lists#index' - get :memberships, to: 'user_group_memberships#index' + get :memberships, to: 'memberships#index' end get :my_groups, to: 'groups#index_mine' @@ -86,6 +86,7 @@ end resources :profile_fields resources :workflows + resources :memberships resources :user_group_memberships resources :status_group_memberships resources :relationships From 2a96d5b1d2e22ea3444c769ab9e48a8e8d7e4df5 Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Tue, 15 Sep 2015 16:38:57 +0200 Subject: [PATCH 17/42] connected groups: fixing spec for Group#build_membership after changed behaviour Now, a `Membership` is built rather than a `UserGroupMembership :< DagLink`. --- spec/models/group_mixins/memberships_spec.rb | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/spec/models/group_mixins/memberships_spec.rb b/spec/models/group_mixins/memberships_spec.rb index 5678bb9dc..7c627b86b 100644 --- a/spec/models/group_mixins/memberships_spec.rb +++ b/spec/models/group_mixins/memberships_spec.rb @@ -76,11 +76,9 @@ describe "#build_membership" do subject { @group.build_membership } - it { should be_kind_of UserGroupMembership } - its(:ancestor_type) { should == 'Group' } - its(:ancestor_id) { should == @group.id } - its(:descendant_type) { should == 'User' } - its(:descendant_id) { should == nil } + it { should be_kind_of Membership } + its(:group) { should == @group } + its(:user) { should == nil } its(:new_record?) { should == true } end From 4291179456ebc710a5a95feb86d153b02570409e Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Wed, 16 Sep 2015 07:12:34 +0200 Subject: [PATCH 18/42] connected groups: moving specs from membership_spec to membership_collection_spec --- .../concerns/membership_collection_spec.rb | 142 ++++++++++++++++++ spec/models/membership_spec.rb | 123 +-------------- 2 files changed, 143 insertions(+), 122 deletions(-) create mode 100644 spec/models/concerns/membership_collection_spec.rb diff --git a/spec/models/concerns/membership_collection_spec.rb b/spec/models/concerns/membership_collection_spec.rb new file mode 100644 index 000000000..2381f7cd1 --- /dev/null +++ b/spec/models/concerns/membership_collection_spec.rb @@ -0,0 +1,142 @@ +require 'spec_helper' + +describe MembershipCollection do + + # Example: + # + # group1 --- page1 --- group2 --- group3 --- user1 + # | + # |------- user2 + # + before do + @group1 = create :group, name: 'group1' + @page1 = @group1.child_pages.create title: 'page1' + @group2 = @page1.child_groups.create name: 'group2' + @group3 = @group2.child_groups.create name: 'group3' + @user1 = create :user; @group3 << @user1 + @user2 = create :user; @group1 << @user2 + end + + describe "#where" do + describe "Membership.where(user: ..., group: ...)" do + describe "for a direct membership" do + subject { Membership.where(user: @user1, group: @group3) } + its(:count) { should == 1 } + its('first.user') { should == @user1 } + its('first.group') { should == @group3 } + its('first.direct?') { should == true } + end + describe "for an indirect membership" do + subject { Membership.where(user: @user1, group: @group2) } + its(:count) { should == 1 } + its('first.user') { should == @user1 } + its('first.group') { should == @group2 } + its('first.direct?') { should == false } + end + end + + describe "Membership.where(user: @user1)" do + # + # page1 -------| + # group4 --- group2 --- group3 --- user1 + # + before { @group4 = @group2.parent_groups.create name: 'group4' } + subject { Membership.where(user: @user1) } + it { should be_kind_of MembershipCollection } + its(:to_a) { should be_kind_of Array } + its(:first) { should be_kind_of Membership } + its(:count) { should == 3 } + it "should not perform too many queries" do + # (User, Group, DagLink, Flag) for the direct links + # (Group, Flag, Flag) for each generation hop, in this case, 3 hops, without caching. + count_queries(13) { subject.to_a }.should <= 13 # scales with number of hops + end + describe "when connected groups are already cached" do + before { @user1.connected_ancestor_groups } + it "should not perform too many queries" do + # (User, Group, DagLink, Flag) for the direct links + # (Group, Flag) for the indirect connected groups + count_queries(6) { subject.to_a }.should <= 6 # does not scale with number of hops + end + end + end + + describe "Membership.where(group: ...)" do + # + # group2 --- group3 --- user1 + # + describe "for groups that have direct members" do + describe ".where(group: @group3)" do + subject { Membership.where(group: @group3) } + it { should be_kind_of MembershipCollection } + its(:to_a) { should be_kind_of Array } + its(:first) { should be_kind_of Membership } + its(:count) { should == 1 } + its('direct.count') { should == 1 } + its('first.user') { should == @user1 } + its('first.group') { should == @group3 } + end + end + + describe "for groups that have indirect members" do + describe "Membership.where(group: @group2)" do + subject { Membership.where(group: @group2) } + it { should be_kind_of MembershipCollection } + its(:to_a) { should be_kind_of Array } + its(:first) { should be_kind_of Membership } + its(:count) { should == 1 } + its('direct.count') { should == 0 } + its('first.user') { should == @user1 } + its('first.group') { should == @group2 } + end + end + end + + describe "for user and group" do + describe "when the link is direct" do + subject { Membership.where(group: @group3, user: @user1) } + it { should be_kind_of MembershipCollection } + its(:to_a) { should be_kind_of Array } + its(:first) { should be_kind_of Membership } + its(:count) { should == 1 } + its('direct.count') { should == 1 } + end + + describe "when the link is not direct" do + subject { Membership.where(group: @group2, user: @user1) } + it { should be_kind_of MembershipCollection } + its(:to_a) { should be_kind_of Array } + its(:first) { should be_kind_of Membership } + its(:count) { should == 1 } + its('direct.count') { should == 0 } + end + end + end + + describe "#direct" do + it "reduces the scope to direct memberships" do + Membership.where(user: @user1).direct.count.should == 1 + end + + it "should be interchangable" do + Membership.where(user: @user1).direct.to_a.should == Membership.direct.where(user: @user1).to_a + end + end + + describe "#uniq" do + # + # @group1 --- @subgroup1 ------ + # | | + # |------- @subgroup2 --- @user1 + # + before do + @group1 = create :group, name: 'group1' + @subgroup1 = @group1.child_groups.create name: 'subgroup1' + @subgroup2 = @group1.child_groups.create name: 'subgroup2' + @user1 = create :user; @subgroup1 << @user1; @subgroup2 << @user1 + end + subject { Membership.where(user: @user1).uniq } + its(:count) { should == Membership.where(user: @user1).count - 1 } + end + +end \ No newline at end of file diff --git a/spec/models/membership_spec.rb b/spec/models/membership_spec.rb index 2377d4f3f..a822e956c 100644 --- a/spec/models/membership_spec.rb +++ b/spec/models/membership_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe Membership do - + # Example: # # group1 --- page1 --- group2 --- group3 --- user1 @@ -17,102 +17,6 @@ @user2 = create :user; @group1 << @user2 end - describe ".where" do - describe ".where(user: ..., group: ...)" do - describe "for a direct membership" do - subject { Membership.where(user: @user1, group: @group3) } - its(:count) { should == 1 } - its('first.user') { should == @user1 } - its('first.group') { should == @group3 } - its('first.direct?') { should == true } - end - describe "for an indirect membership" do - subject { Membership.where(user: @user1, group: @group2) } - its(:count) { should == 1 } - its('first.user') { should == @user1 } - its('first.group') { should == @group2 } - its('first.direct?') { should == false } - end - end - - describe ".where(user: @user1)" do - # - # page1 -------| - # group4 --- group2 --- group3 --- user1 - # - before { @group4 = @group2.parent_groups.create name: 'group4' } - subject { Membership.where(user: @user1) } - it { should be_kind_of MembershipCollection } - its(:to_a) { should be_kind_of Array } - its(:first) { should be_kind_of Membership } - its(:count) { should == 3 } - it "should not perform too many queries" do - # (User, Group, DagLink, Flag) for the direct links - # (Group, Flag, Flag) for each generation hop, in this case, 3 hops, without caching. - count_queries(13) { subject.to_a }.should <= 13 # scales with number of hops - end - describe "when connected groups are already cached" do - before { @user1.connected_ancestor_groups } - it "should not perform too many queries" do - # (User, Group, DagLink, Flag) for the direct links - # (Group, Flag) for the indirect connected groups - count_queries(6) { subject.to_a }.should <= 6 # does not scale with number of hops - end - end - end - - describe ".where(group: ...)" do - # - # group2 --- group3 --- user1 - # - describe "for groups that have direct members" do - describe ".where(group: @group3)" do - subject { Membership.where(group: @group3) } - it { should be_kind_of MembershipCollection } - its(:to_a) { should be_kind_of Array } - its(:first) { should be_kind_of Membership } - its(:count) { should == 1 } - its('direct.count') { should == 1 } - its('first.user') { should == @user1 } - its('first.group') { should == @group3 } - end - end - - describe "for groups that have indirect members" do - describe ".where(group: @group2)" do - subject { Membership.where(group: @group2) } - it { should be_kind_of MembershipCollection } - its(:to_a) { should be_kind_of Array } - its(:first) { should be_kind_of Membership } - its(:count) { should == 1 } - its('direct.count') { should == 0 } - its('first.user') { should == @user1 } - its('first.group') { should == @group2 } - end - end - end - - describe "for user and group" do - describe "when the link is direct" do - subject { Membership.where(group: @group3, user: @user1) } - it { should be_kind_of MembershipCollection } - its(:to_a) { should be_kind_of Array } - its(:first) { should be_kind_of Membership } - its(:count) { should == 1 } - its('direct.count') { should == 1 } - end - - describe "when the link is not direct" do - subject { Membership.where(group: @group2, user: @user1) } - it { should be_kind_of MembershipCollection } - its(:to_a) { should be_kind_of Array } - its(:first) { should be_kind_of Membership } - its(:count) { should == 1 } - its('direct.count') { should == 0 } - end - end - end - describe ".find" do subject { Membership.find(@dag_link_id) } # @@ -136,31 +40,6 @@ end end - describe ".direct" do - it "reduces the scope to direct memberships" do - Membership.where(user: @user1).direct.count.should == 1 - end - - it "should be interchangable" do - Membership.where(user: @user1).direct.to_a.should == Membership.direct.where(user: @user1).to_a - end - end - - describe ".uniq" do - # - # @group1 --- @subgroup1 ------ - # | | - # |------- @subgroup2 --- @user1 - # - before do - @group1 = create :group, name: 'group1' - @subgroup1 = @group1.child_groups.create name: 'subgroup1' - @subgroup2 = @group1.child_groups.create name: 'subgroup2' - @user1 = create :user; @subgroup1 << @user1; @subgroup2 << @user1 - end - subject { Membership.where(user: @user1).uniq } - its(:count) { should == Membership.where(user: @user1).count - 1 } - end describe "#save!" do subject { @membership.save! } From 080efc5a39b0cae7f0987e9e8dbe49edfdb6fee4 Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Wed, 16 Sep 2015 08:32:16 +0200 Subject: [PATCH 19/42] connected groups: implementing Membership#update_attributes --- app/models/concerns/membership_persistence.rb | 37 ++++++++++++------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/app/models/concerns/membership_persistence.rb b/app/models/concerns/membership_persistence.rb index 675185c1a..9c7730848 100644 --- a/app/models/concerns/membership_persistence.rb +++ b/app/models/concerns/membership_persistence.rb @@ -33,19 +33,15 @@ def save! end def update_attributes!(attrs = {}) - attrs.each do |key, value| - send("#{key}=", value) - end + set_attributes(attrs) save! end - def write_attributes_to_dag_link - dag_link.valid_from = @valid_from - dag_link.valid_to = @valid_to - dag_link.ancestor_id = @group.id - dag_link.descendant_id = @user.id + def update_attributes(attrs = {}) + set_attributes(attrs) + save if direct? end - + def reload @dag_link = nil @valid_from = dag_link.valid_from @@ -53,10 +49,6 @@ def reload return self end - def _read_attribute(key) - send(key) if key.in? [:valid_from, :valid_to] - end - delegate :destroyed?, :new_record?, to: :dag_link def destroyable? @@ -67,6 +59,25 @@ def destroy (destroyable? && dag_link.try(:destroy)) || raise("could not destroy membership #{id}.") end + private + + def write_attributes_to_dag_link + dag_link.valid_from = @valid_from + dag_link.valid_to = @valid_to + dag_link.ancestor_id = @group.id + dag_link.descendant_id = @user.id + end + + def _read_attribute(key) + send(key) if key.in? [:valid_from, :valid_to] + end + + def set_attributes(attrs) + attrs.each do |key, value| + send("#{key}=", value) + end + end + class_methods do def base_class From 7680f77301fa726ab30b0427b52b93009e39c433 Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Wed, 16 Sep 2015 08:34:28 +0200 Subject: [PATCH 20/42] connected groups: implementing MembershipCollection#first_per_group I thought, I would use this for group_members#index. But it turns out, this dos not result in the expected behaviour. In a super group, rather than seeing the date of joining the current direct group, I want to see when the user joined one of the direct groups the first time. --- app/models/membership_collection.rb | 14 +++++++++ .../concerns/membership_collection_spec.rb | 31 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/app/models/membership_collection.rb b/app/models/membership_collection.rb index e65832ece..2ad2c5b58 100644 --- a/app/models/membership_collection.rb +++ b/app/models/membership_collection.rb @@ -18,6 +18,14 @@ def uniq return self end + # If a user has two memberships in a group, differing in the validity range, + # this filter selects the first, i.e. earliest, membership for each group. + # + def first_per_group + @first_per_group = true + return self + end + def to_a memberships = [] if @direct @@ -32,6 +40,12 @@ def to_a end end memberships = memberships.uniq { |m| [m.group.id, m.user.id, m.valid_from, m.valid_to] } if @uniq + if @first_per_group + memberships = memberships.group_by { |m| [m.group, m.user] }.collect do |group_and_user, memberships| + min_valid_from_to_i = memberships.collect { |m| m.valid_from.to_i }.min + memberships.detect { |m| m.valid_from.to_i == min_valid_from_to_i } + end + end return memberships end diff --git a/spec/models/concerns/membership_collection_spec.rb b/spec/models/concerns/membership_collection_spec.rb index 2381f7cd1..91eaaa7bb 100644 --- a/spec/models/concerns/membership_collection_spec.rb +++ b/spec/models/concerns/membership_collection_spec.rb @@ -138,5 +138,36 @@ subject { Membership.where(user: @user1).uniq } its(:count) { should == Membership.where(user: @user1).count - 1 } end + + describe "#first_per_group", :focus do + # If a user has two memberships in a group, differing in the validity range, + # this filter selects the first, i.e. earliest, membership for each group. + # + # @group1 --- @subgroup1 ------ + # | | + # |------- @subgroup2 --- @user1 + # + before do + @group1 = create :group, name: 'group1' + @subgroup1 = @group1.child_groups.create name: 'subgroup1' + @subgroup2 = @group1.child_groups.create name: 'subgroup2' + @user1 = create :user; @subgroup1 << @user1; @subgroup2 << @user1 + @time1 = 1.year.ago; @time2 = 6.months.ago; @time3 = 2.months.ago; @time4 = nil + Membership.where(user: @user1, group: @subgroup1).first.update_attributes valid_from: @time1, valid_to: @time2 + Membership.where(user: @user1, group: @subgroup2).first.update_attributes valid_from: @time3, valid_to: @time4 + end + describe "with past memberships" do + subject { Membership.where(group: @group1).first_per_group } + it { should be_kind_of MembershipCollection } + its(:count) { should == 1 } + its('first.valid_from.to_i') { should == @time1.to_i } + end + describe "only current memberships" do + subject { Membership.where(group: @group1).now.first_per_group } + it { should be_kind_of MembershipCollection } + its(:count) { should == 1 } + its('first.valid_from.to_i') { should == @time3.to_i } + end + end end \ No newline at end of file From 69596d004570f1f374ba362aaff09fa6245fbe6c Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Wed, 16 Sep 2015 12:01:23 +0200 Subject: [PATCH 21/42] connected groups: moving spec to correct folder --- spec/models/{concerns => }/membership_collection_spec.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) rename spec/models/{concerns => }/membership_collection_spec.rb (99%) diff --git a/spec/models/concerns/membership_collection_spec.rb b/spec/models/membership_collection_spec.rb similarity index 99% rename from spec/models/concerns/membership_collection_spec.rb rename to spec/models/membership_collection_spec.rb index 91eaaa7bb..df9d95663 100644 --- a/spec/models/concerns/membership_collection_spec.rb +++ b/spec/models/membership_collection_spec.rb @@ -139,7 +139,7 @@ its(:count) { should == Membership.where(user: @user1).count - 1 } end - describe "#first_per_group", :focus do + describe "#first_per_group" do # If a user has two memberships in a group, differing in the validity range, # this filter selects the first, i.e. earliest, membership for each group. # @@ -169,5 +169,6 @@ its('first.valid_from.to_i') { should == @time3.to_i } end end + end \ No newline at end of file From 281171538bcb75dea2c94bd9608610131786478b Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Wed, 16 Sep 2015 19:38:50 +0200 Subject: [PATCH 22/42] connected groups: Implementing MembershipCollection#join_validity_ranges_of_indirect_memberships. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This joins indirect memberships when they are of the same user and the same group but different validity ranges: |-----------| first indirect membership in @group1 |--------- second indirect membership in @group2 |--------------------- joined indirect membership This is needed, since in `group_members#index` of indirect groups, the users should be listed only once. They should be listed with the earliest valid_from of the direct memberships. It’s not enough to use `MembershipCollection#first_per_group`, because this would only consider current direct memberships. --- .../membership_collection_validity_range.rb | 41 +++--- app/models/membership_collection.rb | 112 ++++++++++++---- spec/models/membership_collection_spec.rb | 126 ++++++++++++++++++ 3 files changed, 235 insertions(+), 44 deletions(-) diff --git a/app/models/concerns/membership_collection_validity_range.rb b/app/models/concerns/membership_collection_validity_range.rb index 01969981a..035996938 100644 --- a/app/models/concerns/membership_collection_validity_range.rb +++ b/app/models/concerns/membership_collection_validity_range.rb @@ -143,25 +143,28 @@ def dag_links_for(attrs = {}) links = links.where(descendant_id: attrs[:user_ids]) if attrs[:user_ids] links = links.where(ancestor_id: attrs[:group_ids]) if attrs[:group_ids] - # Validity Perspective - # - links = links.valid if @valid - links = links.invalid if @invalid - links = links.with_invalid if @with_invalid - - # Time Perspective - # - links = links.now if @now - links = links.past if @past - links = links.with_past if @with_past - links = links.now_and_in_the_past if @now_and_in_the_past - links = links.at_time(@at_time) if @at_time - links = links.this_year if @this_year - links = links.started_after(@started_after) if @started_after - - # Include the associated objects to avoid the N+1 problem. - # - links = links.includes(:ancestor, :descendant) + unless attrs[:ignore_validity_range_filters] + # Validity Perspective + # + links = links.valid if @valid + links = links.invalid if @invalid + links = links.with_invalid if @with_invalid + + # Time Perspective + # + links = links.now if @now + links = links.past if @past + links = links.with_past if @with_past + links = links.now_and_in_the_past if @now_and_in_the_past + links = links.at_time(@at_time) if @at_time + links = links.this_year if @this_year + links = links.started_after(@started_after) if @started_after + end + unless attrs[:no_eager_loading] + # Include the associated objects to avoid the N+1 problem. + # + links = links.includes(:ancestor, :descendant) + end return links end diff --git a/app/models/membership_collection.rb b/app/models/membership_collection.rb index 2ad2c5b58..59c104671 100644 --- a/app/models/membership_collection.rb +++ b/app/models/membership_collection.rb @@ -10,6 +10,13 @@ def where(constraints) def direct @direct = true + @indirect = false + return self + end + + def indirect + @indirect = true + @direct = false return self end @@ -26,17 +33,33 @@ def first_per_group return self end + # Join the validity ranges of indirect memberships. + # + # group1 + # |------- subgroup1 -----| + # |------- subgroup2 --- user1 + # + # First, user1 joins subgroup1, then moves to subgroup2. + # + # |-----------| first indirect membership in group1 + # |--------- second indirect membership in group2 + # |--------------------- joined indirect membership + # + def join_validity_ranges_of_indirect_memberships + @join_validity_ranges_of_indirect_memberships = true + return self + end + def to_a memberships = [] - if @direct - memberships = find_all_direct_memberships - else - if @user and not @group - memberships = find_all_memberships_by_user + memberships += find_all_direct_memberships unless @indirect + unless @direct + memberships += if @user and not @group + find_all_indirect_memberships_by_user elsif @group and not @user - memberships = find_all_memberships_by_group + find_all_indirect_memberships_by_group elsif @user and @group - memberships = find_all_memberships_by_user_and_group + find_all_indirect_memberships_by_user_and_group end end memberships = memberships.uniq { |m| [m.group.id, m.user.id, m.valid_from, m.valid_to] } if @uniq @@ -57,37 +80,76 @@ def dag_links dag_links_for user: @user, group: @group end - def find_all_direct_memberships - dag_links.collect do |direct_link| + def find_all_direct_memberships(reload = false) + @direct_memberships = nil if reload + @direct_memberships ||= dag_links.collect do |direct_link| Membership.new(user: direct_link.descendant, group: direct_link.ancestor, valid_from: direct_link.valid_from, valid_to: direct_link.valid_to) end end - def find_all_memberships_by_user - find_all_direct_memberships.collect do |direct_membership| - [ direct_membership ] + direct_membership.group.connected_ancestor_groups.collect do |ancestor_group| + def find_all_indirect_memberships_by_user + if @join_validity_ranges_of_indirect_memberships + indirect_groups = find_all_direct_memberships.collect { |m| m.group.connected_ancestor_groups }.flatten.uniq + indirect_groups.collect do |ancestor_group| + dag_links = dag_links_for( + group_ids: ancestor_group.connected_descendant_group_ids, user_ids: [@user.id], + ignore_validity_range_filters: true, no_eager_loading: true) Membership.new(user: @user, group: ancestor_group, - valid_from: direct_membership.valid_from, valid_to: direct_membership.valid_to) + valid_from: min_valid_from_of(dag_links), valid_to: max_valid_to_of(dag_links)) + end + else + find_all_direct_memberships.collect do |direct_membership| + direct_membership.group.connected_ancestor_groups.collect do |ancestor_group| + Membership.new(user: @user, group: ancestor_group, + valid_from: direct_membership.valid_from, valid_to: direct_membership.valid_to) + end end end.flatten end - def find_all_memberships_by_group - find_all_direct_memberships + - dag_links_for(group_ids: @group.connected_descendant_group_ids).collect do |direct_link| - Membership.new(user: direct_link.descendant, group: @group, - valid_from: direct_link.valid_from, valid_to: direct_link.valid_to) + def find_all_indirect_memberships_by_group + if @join_validity_ranges_of_indirect_memberships + user_ids = dag_links_for(group_ids: @group.connected_descendant_group_ids, no_eager_loading: true).pluck(:descendant_id).uniq + user_ids.collect do |user_id| + dag_links = dag_links_for( + group_ids: @group.connected_descendant_group_ids, user_ids: [user_id], + ignore_validity_range_filters: true, no_eager_loading: true) + Membership.new(user: User.find(user_id), group: @group, + valid_from: min_valid_from_of(dag_links), valid_to: max_valid_to_of(dag_links)) + end + else + dag_links_for(group_ids: @group.connected_descendant_group_ids).collect do |direct_link| + Membership.new(user: direct_link.descendant, group: @group, + valid_from: direct_link.valid_from, valid_to: direct_link.valid_to) + end end end - def find_all_memberships_by_user_and_group - find_all_direct_memberships + - @group.connected_descendant_groups.collect do |descendant_group| - dag_links_for(group: descendant_group, user: @user).collect do |link| - Membership.new(user: @user, group: @group, valid_from: link.valid_from, valid_to: link.valid_to) - end - end.flatten - [nil] + def find_all_indirect_memberships_by_user_and_group + if @join_validity_ranges_of_indirect_memberships + dag_links = dag_links_for( + group_ids: @group.connected_descendant_group_ids, user_ids: [@user.id], + ignore_validity_range_filters: true, no_eager_loading: true) + [ Membership.new(user: @user, group: @group, + valid_from: min_valid_from_of(dag_links), valid_to: max_valid_to_of(dag_links)) ] + else + @group.connected_descendant_groups.collect do |descendant_group| + dag_links_for(group: descendant_group, user: @user).collect do |link| + Membership.new(user: @user, group: @group, valid_from: link.valid_from, valid_to: link.valid_to) + end + end.flatten - [nil] + end + end + + def min_valid_from_of(dag_links) + valid_from_nil = dag_links.where(valid_from: nil).present? + min_valid_from = valid_from_nil ? nil : dag_links.minimum(:valid_from) + end + + def max_valid_to_of(dag_links) + valid_to_nil = dag_links.where(valid_to: nil).present? + max_valid_to = valid_to_nil ? nil : dag_links.maximum(:valid_to) end end \ No newline at end of file diff --git a/spec/models/membership_collection_spec.rb b/spec/models/membership_collection_spec.rb index df9d95663..58ece7c71 100644 --- a/spec/models/membership_collection_spec.rb +++ b/spec/models/membership_collection_spec.rb @@ -170,5 +170,131 @@ end end + describe "#join_validity_ranges_of_indirect_memberships" do + # Join the validity ranges of indirect memberships. + # + # @group1 + # |------- @subgroup1 -----| + # |------- @subgroup2 --- @user1 + # + # First, user1 joins subgroup1, then moves to subgroup2. + # + # |-----------| first indirect membership in @group1 + # |--------- second indirect membership in @group2 + # |--------------------- joined indirect membership + # + before do + @group1 = create :group, name: 'group1' + @subgroup1 = @group1.child_groups.create name: 'subgroup1' + @subgroup2 = @group1.child_groups.create name: 'subgroup2' + @user1 = create :user; @subgroup1 << @user1; @subgroup2 << @user1 + @time1 = 1.year.ago; @time2 = 6.months.ago; @time3 = 2.months.ago; @time4 = nil + Membership.where(user: @user1, group: @subgroup1).first.update_attributes valid_from: @time1, valid_to: @time2 + Membership.where(user: @user1, group: @subgroup2).first.update_attributes valid_from: @time3, valid_to: @time4 + end + describe "for a group" do + subject { Membership.where(group: @group1).join_validity_ranges_of_indirect_memberships } + it { should be_kind_of MembershipCollection } + it "should join the indirect memberships, i.e. count only one membership, not two" do + subject.count.should == 1 + end + its('first.valid_from.to_i') { should == @time1.to_i } + describe "for a right-open interval" do + before do + @time4 = nil + Membership.where(user: @user1, group: @subgroup2).first.update_attributes valid_from: @time3, valid_to: @time4 + end + its('first.valid_from.to_i') { should == @time1.to_i } + its('first.valid_to.to_i') { should == @time4.to_i } + end + describe "for a left-open interval" do + before do + @time1 = nil + @time4 = 1.day.ago + Membership.where(user: @user1, group: @subgroup1).first.update_attributes valid_from: @time1, valid_to: @time2 + Membership.where(user: @user1, group: @subgroup2).first.update_attributes valid_from: @time3, valid_to: @time4 + end + its('first.valid_from.to_i') { should == @time1.to_i } + its('first.valid_to.to_i') { should == @time4.to_i } + end + describe "for a closed interval" do + before do + @time4 = 1.day.ago + Membership.where(user: @user1, group: @subgroup2).first.update_attributes valid_from: @time3, valid_to: @time4 + end + its('first.valid_from.to_i') { should == @time1.to_i } + its('first.valid_to.to_i') { should == @time4.to_i } + end + end + describe "for a user" do + subject { Membership.where(user: @user1).join_validity_ranges_of_indirect_memberships } + it { should be_kind_of MembershipCollection } + it "should join the indirect memberships, i.e. count only one membership, not two" do + subject.count.should == 3 + subject.indirect.count.should == 1 + end + describe "for a right-open interval" do + before do + @time4 = nil + Membership.where(user: @user1, group: @subgroup2).first.update_attributes valid_from: @time3, valid_to: @time4 + end + its('indirect.first.valid_from.to_i') { should == @time1.to_i } + its('indirect.first.valid_to.to_i') { should == @time4.to_i } + end + describe "for a left-open interval" do + before do + @time1 = nil + @time4 = 1.day.ago + Membership.where(user: @user1, group: @subgroup1).first.update_attributes valid_from: @time1, valid_to: @time2 + Membership.where(user: @user1, group: @subgroup2).first.update_attributes valid_from: @time3, valid_to: @time4 + end + its('indirect.first.valid_from.to_i') { should == @time1.to_i } + its('indirect.first.valid_to.to_i') { should == @time4.to_i } + end + describe "for a closed interval" do + before do + @time4 = 1.day.ago + Membership.where(user: @user1, group: @subgroup2).first.update_attributes valid_from: @time3, valid_to: @time4 + end + its('indirect.first.valid_from.to_i') { should == @time1.to_i } + its('indirect.first.valid_to.to_i') { should == @time4.to_i } + end + end + describe "for user and group" do + subject { Membership.where(user: @user1, group: @group1).join_validity_ranges_of_indirect_memberships } + it { should be_kind_of MembershipCollection } + it "should join the indirect memberships, i.e. count only one membership, not two" do + subject.count.should == 1 + subject.indirect.count.should == 1 + end + describe "for a right-open interval" do + before do + @time4 = nil + Membership.where(user: @user1, group: @subgroup2).first.update_attributes valid_from: @time3, valid_to: @time4 + end + its('first.valid_from.to_i') { should == @time1.to_i } + its('first.valid_to.to_i') { should == @time4.to_i } + end + describe "for a left-open interval" do + before do + @time1 = nil + @time4 = 1.day.ago + Membership.where(user: @user1, group: @subgroup1).first.update_attributes valid_from: @time1, valid_to: @time2 + Membership.where(user: @user1, group: @subgroup2).first.update_attributes valid_from: @time3, valid_to: @time4 + end + its('first.valid_from.to_i') { should == @time1.to_i } + its('first.valid_to.to_i') { should == @time4.to_i } + end + describe "for a closed interval" do + before do + @time4 = 1.day.ago + Membership.where(user: @user1, group: @subgroup2).first.update_attributes valid_from: @time3, valid_to: @time4 + end + its('first.valid_from.to_i') { should == @time1.to_i } + its('first.valid_to.to_i') { should == @time4.to_i } + end + end + + end end \ No newline at end of file From 68e8b26587c329f57d32bcf03f0b5f3f36b60bfe Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Wed, 16 Sep 2015 19:51:02 +0200 Subject: [PATCH 23/42] connected groups: migrating `group_members#index` to the new mechanism. --- app/controllers/group_members_controller.rb | 12 ++------- app/controllers/groups_controller.rb | 12 ++------- app/models/group_mixins/memberships.rb | 28 +++++++++++++-------- 3 files changed, 21 insertions(+), 31 deletions(-) diff --git a/app/controllers/group_members_controller.rb b/app/controllers/group_members_controller.rb index 15cfa6836..0a0402639 100644 --- a/app/controllers/group_members_controller.rb +++ b/app/controllers/group_members_controller.rb @@ -33,19 +33,11 @@ def load_and_authorize_memberships @memberships = @memberships.started_after(params[:valid_from].to_datetime) if params[:valid_from].present? allowed_members = @group.members.accessible_by(current_ability) - allowed_memberships = @group.memberships.where(descendant_id: allowed_members.map(&:id)) - @memberships = @memberships & allowed_memberships + @memberships = @memberships.to_a.select { |membership| membership.user.in? allowed_members } end def load_members_from_memberships - # Fill also the members into a separate variable. - # - @members = @group.members.includes(:links_as_child).where(dag_links: {id: @memberships.map(&:id)}) - - # For some special groups, the first method of retreiving the members does not work. - # Fallback to these slower methods: - @members = User.includes(:links_as_child).where(dag_links: {id: @memberships.map(&:id)}) if @members.empty? - @members = @memberships.collect { |membership| membership.user } if @members.empty? + @members = @memberships.collect { |membership| membership.user } end def load_own_memberships diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index da8785c22..e47a7e6c6 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -52,19 +52,11 @@ def show # Make sure only members that are allowed to be seen are in this array! # allowed_member_ids = @group.members.accessible_by(current_ability).pluck(:id) - allowed_memberships = @group.memberships.where(descendant_id: allowed_member_ids) - @memberships = @memberships & allowed_memberships + @memberships = @memberships.to_a.select { |m| m.user.id.in? allowed_member_ids } end Rack::MiniProfiler.step('groups#show controller: fetch members') do - # Fill also the members into a separate variable. - # - @members = @group.members.includes(:links_as_child).where(dag_links: {id: @memberships.map(&:id)}) - - # For some special groups, the first method of retreiving the members does not work. - # Fallback to these slower methods: - @members = User.includes(:links_as_child).where(dag_links: {id: @memberships.map(&:id)}) if @members.empty? - @members = @memberships.collect { |membership| membership.user } if @members.empty? + @members = @memberships.collect { |membership| membership.user } end # for performance reasons deactivated for the moment. diff --git a/app/models/group_mixins/memberships.rb b/app/models/group_mixins/memberships.rb index 9b3409e13..c7a240cc4 100644 --- a/app/models/group_mixins/memberships.rb +++ b/app/models/group_mixins/memberships.rb @@ -107,17 +107,23 @@ def memberships_including_members # memberships. # def memberships_for_member_list - cached do - if corporation? - ( - memberships_including_members - - becomes(Corporation).former_members_memberships - - becomes(Corporation).deceased_members_memberships - ) - else - memberships_including_members - end - end + Membership.where(group: self).now.join_validity_ranges_of_indirect_memberships + + # + # TODO: Use and override `memberships` instead. + # + + # cached do + # if corporation? + # ( + # memberships_including_members - + # becomes(Corporation).former_members_memberships - + # becomes(Corporation).deceased_members_memberships + # ) + # else + # memberships_including_members + # end + # end end def memberships_for_member_list_count cached { memberships_for_member_list.count } From 72bc14c093dee2fd0fecd096012d36a1850a8f43 Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Wed, 16 Sep 2015 21:48:41 +0200 Subject: [PATCH 24/42] connected groups: Migrating `User#memberships` and `User#groups` to the new mechanism. This includes interface changes, since `User#groups` is now an `Array`, not an Association. This only affects write access. --- app/models/concerns/user_memberships.rb | 35 +++++ app/models/membership_collection.rb | 5 + app/models/user.rb | 2 +- app/models/user_mixins/memberships.rb | 75 ---------- spec/models/concerns/user_memberships_spec.rb | 116 +++++++++++++++ spec/models/user_mixins/memberships_spec.rb | 138 ------------------ 6 files changed, 157 insertions(+), 214 deletions(-) create mode 100644 app/models/concerns/user_memberships.rb delete mode 100644 app/models/user_mixins/memberships.rb create mode 100644 spec/models/concerns/user_memberships_spec.rb delete mode 100644 spec/models/user_mixins/memberships_spec.rb diff --git a/app/models/concerns/user_memberships.rb b/app/models/concerns/user_memberships.rb new file mode 100644 index 000000000..368a41ae2 --- /dev/null +++ b/app/models/concerns/user_memberships.rb @@ -0,0 +1,35 @@ +concern :UserMemberships do + + def memberships + Membership.where(user: self).now + end + + def direct_memberships + memberships.direct + end + + def indirect_memberships + memberships.indirect + end + + def memberships_in(group) + memberships.where(group: group) + end + + def membership_in(group) + memberships_in(group).first + end + + def groups + memberships.map(&:group) + end + + def direct_groups + direct_memberships.map(&:group) + end + + def indirect_groups + indirect_memberships.map(&:group) + end + +end \ No newline at end of file diff --git a/app/models/membership_collection.rb b/app/models/membership_collection.rb index 59c104671..69ebe0a18 100644 --- a/app/models/membership_collection.rb +++ b/app/models/membership_collection.rb @@ -73,6 +73,11 @@ def to_a end delegate :count, :first, :last, to: :to_a + delegate :map, to: :to_a + + def include?(*other_memberships) + to_a.collect { |m| [m.group.id, m.user.id, m.valid_from, m.valid_to] }.include?(*other_memberships.collect { |m| [m.group.id, m.user.id, m.valid_from, m.valid_to] }) + end private diff --git a/app/models/user.rb b/app/models/user.rb index 91cb25245..436518c8c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -52,7 +52,7 @@ class User < ActiveRecord::Base # Mixins # ========================================================================================== - include UserMixins::Memberships + include UserMemberships include UserMixins::Identification include ProfileableMixins::Address include UserCorporations diff --git a/app/models/user_mixins/memberships.rb b/app/models/user_mixins/memberships.rb deleted file mode 100644 index 3f712f7d8..000000000 --- a/app/models/user_mixins/memberships.rb +++ /dev/null @@ -1,75 +0,0 @@ -# -# This module contains the methods of the User model regarding the associated -# user group memberships and groups. -# -module UserMixins::Memberships - - extend ActiveSupport::Concern - - included do - - # User Group Memberships - # ========================================================================================== - - # This associates all UserGroupMembership objects of the group, including indirect - # memberships. - # - has_many( :memberships, - -> { where ancestor_type: 'Group', descendant_type: 'User' }, - class_name: 'UserGroupMembership', - foreign_key: :descendant_id ) - - # This associates all memberships of the group that are direct, i.e. direct - # parent_group-child_user memberships. - # - has_many( :direct_memberships, - -> { where ancestor_type: 'Group', descendant_type: 'User', direct: true }, - class_name: 'UserGroupMembership', - foreign_key: :descendant_id ) - - # This associates all memberships of the group that are indirect, i.e. - # ancestor_group-descendant_user memberships, where groups are between the - # ancestor_group and the descendant_user. - # - has_many( :indirect_memberships, - -> { where ancestor_type: 'Group', descendant_type: 'User', direct: false }, - class_name: 'UserGroupMembership', - foreign_key: :descendant_id ) - - - # This returns the membership of the user in the given group if existant. - # - def membership_in( group ) - memberships.where(ancestor_id: group.id).limit(1).first - end - - - # Groups the user is member of - # ========================================================================================== - - # This associates the groups the user is member of, direct as well as indirect. - # - has_many(:groups, - -> { where('dag_links.descendant_type' => 'User').uniq }, - through: :memberships, - source: :ancestor, source_type: 'Group' - ) - - # This associates only the direct groups. - # - has_many(:direct_groups, - -> { where('dag_links.descendant_type' => 'User', 'dag_links.direct' => true).uniq }, - through: :direct_memberships, - source: :ancestor, source_type: 'Group' - ) - - # This associates only the indirect groups. - # - has_many(:indirect_groups, - -> { where('dag_links.descendant_type' => 'User', 'dag_links.direct' => false).uniq }, - through: :indirect_memberships, - source: :ancestor, source_type: 'Group' - ) - - end -end diff --git a/spec/models/concerns/user_memberships_spec.rb b/spec/models/concerns/user_memberships_spec.rb new file mode 100644 index 000000000..62292e2bf --- /dev/null +++ b/spec/models/concerns/user_memberships_spec.rb @@ -0,0 +1,116 @@ +require 'spec_helper' + +describe UserMemberships do + + # @indirect_group + # |------------ @group + # | |------ @user1 + # | |------ @user2 + # | + # |------------ @group2 + # + before do + @group = create(:group) + @user1 = create(:user); @group.assign_user(@user1) + @user2 = create(:user); @group.assign_user(@user2) + @user = @user1 + @membership1 = Membership.where(user: @user1, group: @group).first + @membership2 = Membership.where(user: @user2, group: @group).first + @indirect_group = @group.parent_groups.create + @indirect_membership1 = Membership.where(user: @user1, group: @indirect_group).first + @indirect_membership2 = Membership.where(user: @user2, group: @indirect_group).first + @group2 = @indirect_group.child_groups.create + end + + describe "(Memberships)" do + describe "#memberships" do + subject { @user1.memberships } + it { should include @membership1 } + it { should include @indirect_membership1 } + it "should not include invalidated memberships" do + @membership1.invalidate at: 10.minutes.ago + subject { should_not include @membership1 } + end + it "should not include invalidated indirect memberships" do + @membership1.invalidate at: 10.minutes.ago + subject { should_not include @indirect_membership1 } + end + end + + describe "#direct_memberships" do + subject { @user1.direct_memberships } + it { should include @membership1 } + it { should_not include @indirect_membership1 } + end + + describe "#indirect_memberships" do + subject { @user1.indirect_memberships } + it { should include @indirect_membership1 } + it { should_not include @membership1 } + end + + describe "#memberships_in(group)" do + describe "for the user being a direct member" do + subject { @user.memberships_in @group } + it { should be_kind_of MembershipCollection } + it { should include @membership1 } + end + describe "for the user being an indirect member" do + subject { @user.memberships_in @indirect_group } + it { should be_kind_of MembershipCollection } + it { should include @indirect_membership1 } + end + end + + describe "#membership_in(group)" do + describe "for the user being a direct member" do + subject { @user.membership_in @group } + it { should == @membership1 } + end + describe "for the user being an indirect member" do + subject { @user.membership_in @indirect_group } + it { should == @indirect_membership1 } + end + end + + describe "#member_of?(group) [defined in UserRoles]" do + describe "for the user being direct member" do + subject { @user.member_of? @group} + it { should == true } + end + describe "for the user being indirect member" do + subject { @user.member_of? @indirect_group } + it { should == true } + end + describe "for the user not being a member" do + subject { @user.member_of? @group2 } + it { should == false } + end + end + end + + describe "(Groups)" do + describe "#groups" do + subject { @user1.groups } + it { should include @group } + it { should include @indirect_group } + it "should not include groups of invalidated memberships" do + @membership1.invalidate at: 10.minutes.ago + subject.should_not include @group + subject.should_not include @indirect_group + end + end + + describe "#direct_groups" do + subject { @user.direct_groups } + it { should include @group } + it { should_not include @indirect_group } + end + + describe "#indirect_groups" do + subject { @user.indirect_groups } + it { should include @indirect_group } + it { should_not include @group } + end + end +end \ No newline at end of file diff --git a/spec/models/user_mixins/memberships_spec.rb b/spec/models/user_mixins/memberships_spec.rb deleted file mode 100644 index b0ea90c9a..000000000 --- a/spec/models/user_mixins/memberships_spec.rb +++ /dev/null @@ -1,138 +0,0 @@ -require 'spec_helper' - -describe UserMixins::Memberships do - - # @indirect_group - # |------------ @group - # | |------ @user1 - # | |------ @user2 - # | - # |------------ @group2 - # - before do - @group = create(:group) - @user1 = create(:user); @group.assign_user(@user1) - @user2 = create(:user); @group.assign_user(@user2) - @user = @user1 - @membership1 = UserGroupMembership.find_by(user: @user1, group: @group) - @membership2 = UserGroupMembership.find_by(user: @user2, group: @group) - @indirect_group = @group.parent_groups.create - @indirect_membership1 = UserGroupMembership.find_by(user: @user1, group: @indirect_group) - @indirect_membership2 = UserGroupMembership.find_by(user: @user2, group: @indirect_group) - @group2 = @indirect_group.child_groups.create - end - - - # User Group Memberships - # ========================================================================================== - - describe "#memberships" do - subject { @user1.memberships } - it { should include @membership1 } - it { should include @indirect_membership1 } - it "should not include invalidated memberships" do - @membership1.invalidate at: 10.minutes.ago - subject { should_not include @membership1 } - end - it "should not include invalidated indirect memberships" do - @membership1.invalidate at: 10.minutes.ago - subject { should_not include @indirect_membership1 } - end - end - - describe "#direct_memberships" do - subject { @user1.direct_memberships } - it { should include @membership1 } - it { should_not include @indirect_membership1 } - end - - describe "#indirect_memberships" do - subject { @user1.indirect_memberships } - it { should include @indirect_membership1 } - it { should_not include @membership1 } - end - - - describe "#membership_in( group )" do - describe "for the user being a direct member" do - subject { @user.membership_in @group } - it { should == @membership1 } - end - describe "for the user being an indirect member" do - subject { @user.membership_in @indirect_group } - it { should == @indirect_membership1 } - end - end - - describe "#member_of?( group )" do - describe "for the user being direct member" do - subject { @user.member_of? @group} - it { should == true } - end - describe "for the user being indirect member" do - subject { @user.member_of? @indirect_group } - it { should == true } - end - describe "for the user not being a member" do - subject { @user.member_of? @group2 } - it { should == false } - end - end - - - # Groups the user is member of - # ========================================================================================== - - describe "#groups" do - subject { @user1.groups } - it { should include @group } - it { should include @indirect_group } - it "should not include groups of invalidated memberships" do - @membership1.invalidate at: 10.minutes.ago - subject.should_not include @group - subject.should_not include @indirect_group - end - end - describe "#groups << group" do - subject { @user.groups << @group2 } - it "should assign the user to the given group" do - @user.should_not be_in @group2.members - subject - @user.should be_in @group2.members - @user.should be_in @group2.direct_members - end - end - describe "#groups.destroy(group)" do - describe "for the membership being direct" do - subject { @user.groups.destroy(@group) } - it "should remove the user from the members list" do - @user1.should be_in @group.members - subject - @user1.should_not be_in @group.members - end - it "should remove the membership permanently" do - subject - UserGroupMembership.with_invalid.find_by_user_and_group(@user1, @group).should == nil - end - end - describe "for the membership being indirect" do - subject { @user.groups.destroy(@indirect_group) } - it "should raise an error" do - expect { subject }.to raise_error - end - end - end - - describe "#direct_groups" do - subject { @user.direct_groups } - it { should include @group } - it { should_not include @indirect_group } - end - - describe "#indirect_groups" do - subject { @user.indirect_groups } - it { should include @indirect_group } - it { should_not include @group } - end - -end From 1937c061c11336aa8ccbecf5b20b24a9c017b0cf Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Sun, 27 Sep 2015 09:36:29 +0200 Subject: [PATCH 25/42] connected groups: working on migration from UserGroupMembership to Membership --- app/helpers/corporate_vita_helper.rb | 34 ++++---- app/helpers/events_helper.rb | 4 +- app/helpers/groups_helper.rb | 2 +- app/models/ability.rb | 3 + .../membership_collection_validity_range.rb | 2 +- app/models/concerns/membership_persistence.rb | 8 +- app/models/concerns/membership_review.rb | 17 ++++ .../concerns/membership_validity_range.rb | 2 +- app/models/concerns/user_memberships.rb | 6 +- app/models/concerns/user_roles.rb | 15 +++- app/models/dag_link.rb | 1 + app/models/group_collection.rb | 41 ++++++++++ app/models/group_mixins/memberships.rb | 4 +- app/models/membership.rb | 26 ++++-- app/models/membership_collection.rb | 16 +++- app/models/officer_group.rb | 14 +++- app/models/role.rb | 12 ++- app/models/status_group.rb | 2 +- app/models/user.rb | 21 +++-- app/views/users/_corporate_vita.html.haml | 4 +- spec/features/corporate_vita_spec.rb | 18 ++--- spec/features/events_spec.rb | 7 +- spec/features/group_post_spec.rb | 8 +- .../models/concerns/membership_review_spec.rb | 66 +++++++++++++++ spec/models/concerns/user_memberships_spec.rb | 23 +++++- spec/models/group_collection_spec.rb | 80 +++++++++++++++++++ spec/models/membership_collection_spec.rb | 52 ++++++++++++ spec/models/user_spec.rb | 52 ++++++++---- spec/spec_helper.rb | 1 + 29 files changed, 445 insertions(+), 96 deletions(-) create mode 100644 app/models/concerns/membership_review.rb create mode 100644 app/models/group_collection.rb create mode 100644 spec/models/concerns/membership_review_spec.rb create mode 100644 spec/models/group_collection_spec.rb diff --git a/app/helpers/corporate_vita_helper.rb b/app/helpers/corporate_vita_helper.rb index de4998ace..3e4451dc7 100644 --- a/app/helpers/corporate_vita_helper.rb +++ b/app/helpers/corporate_vita_helper.rb @@ -1,33 +1,29 @@ module CorporateVitaHelper - def corporate_vita_for_user( user ) + def corporate_vita_for_user(user) render partial: 'users/corporate_vita', locals: { user: @user, } end - def status_group_membership_valid_from_best_in_place( membership ) - best_in_place( membership, - :valid_from_localized_date, # type: :date, - url: user_group_membership_path( id: membership.id, - controller: :user_group_memberships, - action: :update, - format: :json - ), - :class => "status_group_date_of_joining" - ) + def status_group_membership_valid_from_best_in_place(membership) + best_in_place(membership, + :valid_from_localized_date, # type: :date, + url: membership_path(id: membership.id, + controller: :memberships, + action: :update, + format: :json), + :class => "status_group_date_of_joining") end - def status_group_membership_promoted_on_event( membership ) + def status_group_membership_promoted_on_event(membership) event = membership.event - best_in_place( membership, - :event_by_name, - url: status_group_membership_path(membership), - class: 'status_event_by_name', + best_in_place(membership, + :event_by_name, + url: status_group_membership_path(membership), + class: 'status_event_by_name', # display_with: lambda do |v| # link_to membership.event.name, membership.event, :class => 'status_event_label' # end - ) + ) end - - end diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index b677ca505..0dd366253 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -5,11 +5,11 @@ def group_to_create_the_event_for end def groups_the_current_user_can_create_events_for - current_user.groups.find_all_by_flag(:officers_parent).collect { |op| op.parent_groups.first } + current_user.officer_groups.collect { |officer_group| officer_group.scope_group } - [nil] end def first_group_the_current_user_can_create_events_for - current_user.groups.find_all_by_flag(:officers_parent).first.try(:parent_groups).try(:first) + current_user.officer_groups.detect { |officer_group| officer_group.scope_group }.try(:scope_group) end def everyone_group_if_the_user_can_create_events_there diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 6a581aa14..a34c945ce 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -47,7 +47,7 @@ def membership_li( user, group ) def sub_group_membership_lis( options = {} ) c = "" c += membership_li( options[ :user ], options[ :group ] ) - sub_groups_where_user_is_member = options[ :group ].child_groups & options[ :user ].groups + sub_groups_where_user_is_member = options[ :group ].child_groups & options[ :user ].groups.to_a current_indent = options[ :indent ] + 1 max_indent = options[ :max_indent ] current_indent = max_indent if current_indent > max_indent diff --git a/app/models/ability.rb b/app/models/ability.rb index 83d919f25..b5aca3745 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -266,6 +266,9 @@ def rights_for_signed_in_users # in order to update their corporate vita. # can :update, UserGroupMembership, :descendant_id => user.id + can :update, Membership do |membership| + membership.user.id == user.id + end # Everyone who can join an event, can add images to this event. # Then, he will automatically join the event. diff --git a/app/models/concerns/membership_collection_validity_range.rb b/app/models/concerns/membership_collection_validity_range.rb index 035996938..de18fa853 100644 --- a/app/models/concerns/membership_collection_validity_range.rb +++ b/app/models/concerns/membership_collection_validity_range.rb @@ -165,7 +165,7 @@ def dag_links_for(attrs = {}) # links = links.includes(:ancestor, :descendant) end - + return links end diff --git a/app/models/concerns/membership_persistence.rb b/app/models/concerns/membership_persistence.rb index 9c7730848..c64c1f8a3 100644 --- a/app/models/concerns/membership_persistence.rb +++ b/app/models/concerns/membership_persistence.rb @@ -59,6 +59,10 @@ def destroy (destroyable? && dag_link.try(:destroy)) || raise("could not destroy membership #{id}.") end + def _read_attribute(key) + send(key) if key.in? [:valid_from, :valid_to] + end + private def write_attributes_to_dag_link @@ -68,10 +72,6 @@ def write_attributes_to_dag_link dag_link.descendant_id = @user.id end - def _read_attribute(key) - send(key) if key.in? [:valid_from, :valid_to] - end - def set_attributes(attrs) attrs.each do |key, value| send("#{key}=", value) diff --git a/app/models/concerns/membership_review.rb b/app/models/concerns/membership_review.rb new file mode 100644 index 000000000..0a45d94fd --- /dev/null +++ b/app/models/concerns/membership_review.rb @@ -0,0 +1,17 @@ +concern :MembershipReview do + + def needs_review? + direct? && dag_link.has_flag?(:needs_review) + end + + def needs_review=(new_needs_review) + direct? || raise('Only direct memberships can be reviewed.') + dag_link.add_flag :needs_review if new_needs_review + dag_link.remove_flag :needs_review if not new_needs_review + end + + def needs_review! + self.needs_review = true + end + +end \ No newline at end of file diff --git a/app/models/concerns/membership_validity_range.rb b/app/models/concerns/membership_validity_range.rb index a4c0af72d..fa43de720 100644 --- a/app/models/concerns/membership_validity_range.rb +++ b/app/models/concerns/membership_validity_range.rb @@ -18,7 +18,7 @@ # def make_invalid(time = Time.zone.now) dag_link.try(:make_invalid, time) - return self + return self.reload end # This is just an alias for `make_invalid`. diff --git a/app/models/concerns/user_memberships.rb b/app/models/concerns/user_memberships.rb index 368a41ae2..442add9ac 100644 --- a/app/models/concerns/user_memberships.rb +++ b/app/models/concerns/user_memberships.rb @@ -21,15 +21,15 @@ def membership_in(group) end def groups - memberships.map(&:group) + GroupCollection.new(memberships: memberships.join_validity_ranges_of_indirect_memberships) end def direct_groups - direct_memberships.map(&:group) + GroupCollection.new(memberships: direct_memberships) end def indirect_groups - indirect_memberships.map(&:group) + GroupCollection.new(memberships: indirect_memberships.join_validity_ranges_of_indirect_memberships) end end \ No newline at end of file diff --git a/app/models/concerns/user_roles.rb b/app/models/concerns/user_roles.rb index 2ba734479..326d30da3 100644 --- a/app/models/concerns/user_roles.rb +++ b/app/models/concerns/user_roles.rb @@ -31,15 +31,24 @@ def role_for( structureable ) def member_of?( object, options = {} ) if object.kind_of? Group if options[:with_invalid] or options[:also_in_the_past] - self.ancestor_group_ids.include? object.id - else # only current memberships: - self.group_ids.include? object.id # This uses the validity range mechanism + self.groups.with_past.include? object + else + self.groups.now.include? object end else self.ancestors.include? object end end + + # Officer Status + # ------------------------------------------------------------------------------------------ + + def officer_groups + cached { self.groups.select { |g| g.type == "OfficerGroup" } } + end + + # Admins # ------------------------------------------------------------------------------------------ diff --git a/app/models/dag_link.rb b/app/models/dag_link.rb index d62cb050b..db43d9803 100644 --- a/app/models/dag_link.rb +++ b/app/models/dag_link.rb @@ -3,6 +3,7 @@ class DagLink < ActiveRecord::Base attr_accessible :ancestor_id, :ancestor_type, :count, :descendant_id, :descendant_type, :direct if defined? attr_accessible acts_as_dag_links polymorphic: true + has_many_flags # We have to workaround a bug in Rails 3 here. But, since Rails 3 is no longer fully supported, # this is not going to be fixed. diff --git a/app/models/group_collection.rb b/app/models/group_collection.rb new file mode 100644 index 000000000..0338eea4f --- /dev/null +++ b/app/models/group_collection.rb @@ -0,0 +1,41 @@ +class GroupCollection + + def initialize(attrs = {}) + @memberships = attrs[:memberships] || raise('no memberships (MembershipCollection) given.') + @memberships.kind_of?(MembershipCollection) || raise('memberships needs to be a MembershipCollection.') + end + + def to_a + groups = @memberships.to_a.collect { |membership| membership.group } + groups = groups & Group.flagged(@flagged) if @flagged + return groups + end + + def flagged(flag) + @flagged = flag + return self + end + + def find_all_by_flag(flag) + flagged(flag) + end + + def now + @memberships = @memberships.now + return self + end + + def with_past + @memberships = @memberships.with_past + return self + end + + def past + @memberships = @memberships.past + return self + end + + delegate :count, :first, :last, to: :to_a + delegate :map, :collect, :select, :include?, :+, :-, :&, to: :to_a + +end \ No newline at end of file diff --git a/app/models/group_mixins/memberships.rb b/app/models/group_mixins/memberships.rb index c7a240cc4..763a31826 100644 --- a/app/models/group_mixins/memberships.rb +++ b/app/models/group_mixins/memberships.rb @@ -149,9 +149,9 @@ def memberships_this_year # def assign_user( user, options = {} ) if user and not user.in?(self.direct_members) - membership = UserGroupMembership.create(user: user, group: self) + membership = Membership.create(user: user, group: self) time_of_joining = options[:joined_at] || options[:at] || options[:time] || Time.zone.now - membership.update_attribute(:valid_from, time_of_joining) + membership.update_attributes valid_from: time_of_joining return membership end end diff --git a/app/models/membership.rb b/app/models/membership.rb index b68473370..a32f4b447 100644 --- a/app/models/membership.rb +++ b/app/models/membership.rb @@ -22,6 +22,7 @@ class Membership include MembershipPersistence include MembershipValidityRange include MembershipValidityRangeLocalization + include MembershipReview def initialize(attrs = {}) @dag_link = attrs[:dag_link] @@ -51,11 +52,12 @@ def self.direct end def ==(other_membership) - other_membership.kind_of? Membership and - self.group.id == other_membership.group.id and - self.user.id = other_membership.user.id and - self.valid_from == other_membership.valid_from and - self.valid_to == other_membership.valid_to + super || + other_membership.instance_of?(self.class) && + self.group.id == other_membership.group.id && + self.user.id == other_membership.user.id && + self.valid_from.try(:to_i) == other_membership.valid_from.try(:to_i) && + self.valid_to.try(:to_i) == other_membership.valid_to.try(:to_i) end alias_method :eql?, :== @@ -81,6 +83,20 @@ def user_title def user_title=(new_user_title) user = User.find_by_title new_user_title end + + # Invalidate the current membership and move the user to the given group. + # + # membership.move_to other_group + # membership.move_to other_group, at: 1.hour.ago + # + def move_to(group_to_move_in, options = {}) + time = (options[:time] || options[:date] || options[:at] || Time.zone.now).to_datetime + self.invalidate at: time + new_membership = Membership.create(user: self.user, group: group_to_move_in) + new_membership.update_attributes valid_from: time + return new_membership + end + # Create a membership of the user `u` in the group `g`. diff --git a/app/models/membership_collection.rb b/app/models/membership_collection.rb index 69ebe0a18..0bc58067d 100644 --- a/app/models/membership_collection.rb +++ b/app/models/membership_collection.rb @@ -72,11 +72,23 @@ def to_a return memberships end + def groups + GroupCollection.new(memberships: self) + end + delegate :count, :first, :last, to: :to_a - delegate :map, to: :to_a + delegate :map, :collect, :select, :each, to: :to_a def include?(*other_memberships) - to_a.collect { |m| [m.group.id, m.user.id, m.valid_from, m.valid_to] }.include?(*other_memberships.collect { |m| [m.group.id, m.user.id, m.valid_from, m.valid_to] }) + binding.pry + to_a.collect { |m| [m.group.id, m.user.id, m.valid_from, m.valid_to] } + .include?(*other_memberships.collect { |m| [m.group.id, m.user.id, m.valid_from, m.valid_to] }) + end + + def destroy_all + self.each do |membership| + membership.destroy if membership.destroyable? + end end private diff --git a/app/models/officer_group.rb b/app/models/officer_group.rb index 78425cf35..a5b53a478 100644 --- a/app/models/officer_group.rb +++ b/app/models/officer_group.rb @@ -7,7 +7,19 @@ def scope end def structureable - parent.parent_groups.first || parent.parent_pages.first + scope_group || scope_page + end + + # This is the group the officer is responsible for. + # + def scope_group + parent.parent_groups.first + end + + # This is the page the officer is responsible for. + # + def scope_page + parent.parent_pages.first end def parent diff --git a/app/models/role.rb b/app/models/role.rb index 5ccaab5a6..8e8e54c9c 100644 --- a/app/models/role.rb +++ b/app/models/role.rb @@ -42,7 +42,7 @@ def object @object end def group - @object + @object if @object.kind_of?(Group) end # @@ -53,10 +53,14 @@ def current_member? member? && full_member? end + # To be a full member of a `group`, a `user` has + # (a) to be member of the `group` and the `group` has to be flagged `:full_members`. + # (b) to be member of one of the subgroups of `group` that is flagged `:full_members`. + # def full_member? - object.kind_of?(Group) && - ( user.groups.flagged(:full_members).where(id: group.descendant_group_ids).exists? || - user.groups.flagged(:full_members).exists?(group.id) ) + return false unless group + full_members_group_ids = ([group.id] + group.connected_descendant_group_ids) & Group.flagged(:full_members).pluck(:id) + (user.groups.map(&:id) & full_members_group_ids).count > 0 end def member? diff --git a/app/models/status_group.rb b/app/models/status_group.rb index 79b16eb3e..aeb972d80 100644 --- a/app/models/status_group.rb +++ b/app/models/status_group.rb @@ -16,7 +16,7 @@ def self.find_all_by_user(user, options = {}) user_groups = options[:with_invalid] ? user.parent_groups : user.direct_groups user.corporations.collect do |corporation| StatusGroup.find_all_by_corporation(corporation) - end.flatten & user_groups + end.flatten & user_groups.to_a end def self.find_by_user_and_corporation(user, corporation) diff --git a/app/models/user.rb b/app/models/user.rb index 436518c8c..f5f94762a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -450,8 +450,8 @@ def last_group_in_first_corporation def corporate_vita_memberships_in(corporation) Rails.cache.fetch([self, 'corporate_vita_memberships_in', corporation], expires_in: 1.week) do - group_ids = corporation.status_groups.map(&:id) & self.parent_group_ids - self.memberships.with_past.where(ancestor_id: group_ids, ancestor_type: 'Group') + corporation_status_groups = corporation.status_groups + self.memberships.with_past.select { |m| m.group.in? corporation_status_groups } end end @@ -511,16 +511,15 @@ def relationships # all workflows of all groups the user is a member of. # def workflows - my_workflows = [] - self.groups.each do |group| - my_workflows += group.child_workflows - end - return my_workflows + self.groups.collect do |group| + group.child_workflows + end.flatten - [nil] end def workflows_for(group) - (([group.becomes(Group)] + group.descendant_groups) & self.groups) - .collect { |g| g.child_workflows }.select { |w| not w.nil? }.flatten + (self.groups & ([group] + group.connected_descendant_groups)).collect do |group| + group.child_workflows + end.flatten - [nil] end def workflows_by_corporation @@ -647,12 +646,12 @@ def self.find_all_non_hidden # This efficiently returns all flags of the groups the user is currently in. # - # For example, ony can find out with one sql query whether a user is hidden: + # For example, ony can find out with one query whether a user is hidden: # # user.group_flags.include? 'hidden_users' # def group_flags - groups.joins(:flags).pluck('flags.key') + cached { self.groups.collect { |g| f.flags }.flatten } end diff --git a/app/views/users/_corporate_vita.html.haml b/app/views/users/_corporate_vita.html.haml index eec5b6705..70afe0f3e 100644 --- a/app/views/users/_corporate_vita.html.haml +++ b/app/views/users/_corporate_vita.html.haml @@ -5,13 +5,13 @@ - if memberships.count > 0 %tr %th{ colspan: 3}= corporation.title - - for membership in memberships + - memberships.each do |membership| - needs_review = (can?(:manage, user) && membership.needs_review?) - needs_review_class = needs_review ? 'needs_review' : '' %tr{ class: "membership #{needs_review_class}" } %td - if needs_review - = link_to(user_group_membership_path(membership, 'user_group_membership[needs_review]' => false), method: :put, remote: true, :class => 'btn btn-small btn-success confirm-review-button', title: I18n.t(:confirm_review)) do + = link_to(membership_path(membership, 'membership[needs_review]' => false), method: :put, remote: true, :class => 'btn btn-small btn-success confirm-review-button', title: I18n.t(:confirm_review)) do = icon 'ok' %td.membership_valid_from - if can? :update, membership diff --git a/spec/features/corporate_vita_spec.rb b/spec/features/corporate_vita_spec.rb index c4bb85712..b875c701a 100644 --- a/spec/features/corporate_vita_spec.rb +++ b/spec/features/corporate_vita_spec.rb @@ -79,14 +79,14 @@ describe 'change the date of promotion afterwards' do before do @first_promotion_workflow.execute( user_id: @user.id ) - @membership = UserGroupMembership.now_and_in_the_past.find_by_user_and_group( @user, @status_groups.first ) + @membership = Membership.where(user: @user, group: @status_groups.first).first visit user_path( @user ) end it 'should be possible to change the date' do within('#corporate_vita') do - @valid_from_formatted = I18n.localize @membership.valid_from.to_date + @valid_from_formatted = I18n.localize @membership.valid_from.in_time_zone(TEST_TIMEZONE).to_date page.should have_content @valid_from_formatted @@ -97,14 +97,14 @@ page.should have_field 'valid_from_localized_date', with: @valid_from_formatted end - @new_date = 10.days.ago.to_date + @new_date = 10.days.ago.in_time_zone(TEST_TIMEZONE).to_date fill_in "valid_from_localized_date", with: I18n.localize(@new_date) page.should have_no_selector("input") page.should have_content I18n.localize(@new_date) wait_for_ajax; wait_for_ajax # apparently, it needs two in order not to fail - UserGroupMembership.now_and_in_the_past.find(@membership.id).valid_from.to_date.should == @new_date + Membership.find(@membership.id).valid_from.to_date.should == @new_date end end @@ -150,17 +150,17 @@ describe 'change the date of promotion afterwards' do before do @first_promotion_workflow.execute( user_id: @user.id ) - @membership = UserGroupMembership.now_and_in_the_past.find_by_user_and_group( @user, @status_groups.first ) + @membership = Membership.where(user: @user, group: @status_groups.first).first visit user_path( @user ) end it 'should be possible to change the date' do within('#corporate_vita') do - @valid_from_formatted = I18n.localize @membership.valid_from.to_date + @valid_from_formatted = I18n.localize @membership.valid_from.in_time_zone(TEST_TIMEZONE).to_date page.should have_content @valid_from_formatted - + save_and_open_page # activate inplace editing of the date_field first('.best_in_place.status_group_date_of_joining').click @@ -168,14 +168,14 @@ page.should have_field 'valid_from_localized_date', with: @valid_from_formatted end - @new_date = 10.days.ago.to_date + @new_date = 10.days.ago.in_time_zone(TEST_TIMEZONE).to_date fill_in "valid_from_localized_date", with: I18n.localize(@new_date) page.should have_no_selector("input") page.should have_content I18n.localize(@new_date) wait_for_ajax; wait_for_ajax # apparently, it needs two in order not to fail - UserGroupMembership.now_and_in_the_past.find(@membership.id).valid_from.to_date.should == @new_date + Membership.find(@membership.id).valid_from.to_date.should == @new_date end end diff --git a/spec/features/events_spec.rb b/spec/features/events_spec.rb index effa635d3..59adf5864 100644 --- a/spec/features/events_spec.rb +++ b/spec/features/events_spec.rb @@ -229,8 +229,9 @@ end context "for officers", js: true do - background do - @group.officers_parent.child_groups.create(name: 'President').assign_user @user, at: 1.hour.ago + background do + @group.assign_user @user, at: 10.hours.ago + @group.create_officer_group(name: 'President').assign_user @user, at: 1.hour.ago login @user end @@ -342,7 +343,7 @@ background do @corporation = create :corporation_with_status_groups @corporation.status_groups.first.assign_user @user, at: 1.month.ago - @president = @corporation.officers_parent.child_groups.create name: 'President' + @president = @corporation.create_officer_group name: 'President' @president.assign_user @user, at: 5.days.ago @other_event = create :event @other_event.parent_groups << @corporation diff --git a/spec/features/group_post_spec.rb b/spec/features/group_post_spec.rb index 3dd88171d..b9bb3296b 100644 --- a/spec/features/group_post_spec.rb +++ b/spec/features/group_post_spec.rb @@ -5,14 +5,16 @@ background do # - # @super_group + # @parent_group # |-------- @group ------ @other_user - # | - # @officers ----- @user + # |--------- @user + # | + # @officers -- @user # @user = create :user_with_account @other_user = create :user_with_account @group = create :group + @group << @user @group << @other_user @parent_group = create :group @parent_group << @group diff --git a/spec/models/concerns/membership_review_spec.rb b/spec/models/concerns/membership_review_spec.rb new file mode 100644 index 000000000..f4e55a76e --- /dev/null +++ b/spec/models/concerns/membership_review_spec.rb @@ -0,0 +1,66 @@ +require 'spec_helper' + +describe MembershipReview do + before do + @user = create :user + @group = create :group + @membership = Membership.create user: @user, group: @group + end + + describe "needs_review?" do + subject { @membership.needs_review? } + describe "when unset" do + it { should == false } + end + describe "when set to true" do + before { @membership.needs_review = true; @membership.reload } + it { should == true } + end + end + + describe "needs_review=" do + describe "true" do + subject { @membership.needs_review = true; @membership.reload } + describe "when unset" do + specify { subject; @membership.needs_review?.should == true } + end + describe "when set to true" do + before { @membership.needs_review = true } + specify { subject; @membership.needs_review?.should == true } + end + describe "when set to false" do + before { @membership.needs_review = false } + specify { subject; @membership.needs_review?.should == true } + end + end + describe "false" do + subject { @membership.needs_review = false; @membership.reload } + describe "when unset" do + specify { subject; @membership.needs_review?.should == false } + end + describe "when set to true" do + before { @membership.needs_review = true } + specify { subject; @membership.needs_review?.should == false } + end + describe "when set to false" do + before { @membership.needs_review = false } + specify { subject; @membership.needs_review?.should == false } + end + end + end + + describe "needs_review!" do + subject { @membership.needs_review!; @membership.reload } + describe "when unset" do + specify { subject; @membership.needs_review?.should == true } + end + describe "when set to true" do + before { @membership.needs_review = true } + specify { subject; @membership.needs_review?.should == true } + end + describe "when set to false" do + before { @membership.needs_review = false } + specify { subject; @membership.needs_review?.should == true } + end + end +end \ No newline at end of file diff --git a/spec/models/concerns/user_memberships_spec.rb b/spec/models/concerns/user_memberships_spec.rb index 62292e2bf..557eaac80 100644 --- a/spec/models/concerns/user_memberships_spec.rb +++ b/spec/models/concerns/user_memberships_spec.rb @@ -90,14 +90,28 @@ end describe "(Groups)" do + # + # @indirect_group + # |------------ @group + # | |------ @user1 # @membership1 + # | |------ @user2 + # | + # |------------ @group2 + # |------ @user1 + # + before do + Membership.create user: @user1, group: @group2 + end + describe "#groups" do subject { @user1.groups } it { should include @group } it { should include @indirect_group } - it "should not include groups of invalidated memberships" do - @membership1.invalidate at: 10.minutes.ago - subject.should_not include @group - subject.should_not include @indirect_group + describe "when a direct membership has been invalidated" do + before { @membership1.invalidate at: 10.minutes.ago } + it { should_not include @group } + it { should include @group2 } + it { should include @indirect_group } end end @@ -111,6 +125,7 @@ subject { @user.indirect_groups } it { should include @indirect_group } it { should_not include @group } + its(:count) { should == 1 } end end end \ No newline at end of file diff --git a/spec/models/group_collection_spec.rb b/spec/models/group_collection_spec.rb new file mode 100644 index 000000000..f27850dfb --- /dev/null +++ b/spec/models/group_collection_spec.rb @@ -0,0 +1,80 @@ +require 'spec_helper' + +describe GroupCollection do + + # + # group1 --- page1 --- group2 --- group3 --- user1 + # | + # |------- user2 + # + before do + @group1 = create :group, name: 'group1' + @page1 = @group1.child_pages.create title: 'page1' + @group2 = @page1.child_groups.create name: 'group2' + @group3 = @group2.child_groups.create name: 'group3' + @user1 = create :user; @group3 << @user1 + @user2 = create :user; @group1 << @user2 + + @membership_collection = Membership.where(user: @user1) + @group_collection = GroupCollection.new(memberships: @membership_collection) + end + + describe "#to_a" do + subject { @group_collection.to_a } + it { should be_kind_of Array } + it { should include @group3, @group2 } + it { should_not include @group1, @page1, @user2 } + end + + describe "#count" do + subject { @group_collection.count } + it { should == 2 } + end + + describe "#flagged" do + before { @group3.add_flag :test_flag } + subject { @group_collection.flagged(:test_flag) } + it { should be_kind_of GroupCollection } + its(:count) { should == 1 } + its(:to_a) { should include @group3 } + its(:to_a) { should_not include @group2 } + its(:to_a) { should_not include @group1, @page1, @user2 } + end + + describe "(validity range scopes)" do + # + # group1 --- page1 --- group2 --- group3 --- user1 + # | | + # |------- user2 |------ user3 + # | + # |---- group4 --(past)-- user3 + # + before do + @group4 = @group1.child_groups.create name: 'group4' + @user3 = create :user, last_name: 'user3'; @group4 << @user3; @group3 << @user3 + Membership.where(user: @user3, group: @group4).first.invalidate at: 1.week.ago + + @membership_collection = Membership.where(user: @user3) + @group_collection = GroupCollection.new(memberships: @membership_collection) + end + + describe "#now" do + subject { @group_collection.now } + it { should include @group3, @group2 } + it { should_not include @group4, @group1 } + end + + describe "#with_past" do + subject { @group_collection.with_past } + it { should include @group3, @group2 } + it { should include @group4, @group1 } + end + + describe "#past" do + subject { @group_collection.past } + it { should_not include @group3, @group2 } + it { should include @group4, @group1 } + end + + end +end \ No newline at end of file diff --git a/spec/models/membership_collection_spec.rb b/spec/models/membership_collection_spec.rb index 58ece7c71..a9f391f3f 100644 --- a/spec/models/membership_collection_spec.rb +++ b/spec/models/membership_collection_spec.rb @@ -170,6 +170,58 @@ end end + describe "#groups" do + # group1 --- page1 --- group2 --- group3 --- user1 + # | + # |------- user2 + before { @membership_collection = @user1.memberships } + subject { @membership_collection.groups } + it { should be_kind_of GroupCollection } + it { should include @group2, @group3 } + describe "for #direct" do + before { @membership_collection = @membership_collection.direct } + it { should_not include @group2 } + end + describe "for #indirect" do + before { @membership_collection = @membership_collection.indirect } + it { should_not include @group1 } + end + end + + describe "#destroy_all" do + # Example: + # + # group1 --- page1 --- group2 --- group3 --- user1 + # | + # |------- user2 + # |-------group4 --- user1 (past membership) + # + before do + @group4 = @group1.child_groups.create name: 'group4' + @group4 << @user1 + Membership.where(user: @user1, group: @group4).first.invalidate at: 10.days.ago + + @memberships = Membership.where(user: @user1) + end + describe "for #now" do + subject { @memberships.now.destroy_all; Membership.where(user: @user1).groups } + it { should include @group1, @group4 } + it { should_not include @group3, @group2 } + end + + describe "for #past" do + subject { @memberships.past.destroy_all; Membership.where(user: @user1).groups } + it { should_not include @group1, @group4 } + it { should include @group3, @group2 } + end + + describe "for #with_past" do + subject { @memberships.with_past.destroy_all; Membership.where(user: @user1).groups } + it { should_not include @group1, @group4 } + it { should_not include @group3, @group2 } + end + end + describe "#join_validity_ranges_of_indirect_memberships" do # Join the validity ranges of indirect memberships. # diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 3becd3215..a9b65831e 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -708,8 +708,8 @@ it "should include the groups the user is an indirect member of" do subject.should include( Group.everyone ) end - it "should return all ancestor groups" do - subject.should == @user.ancestor_groups + it "should return all connected ancestor groups" do + subject.collect(&:id).sort.should == @user.connected_ancestor_groups.collect(&:id).sort end end @@ -1006,20 +1006,42 @@ # ------------------------------------------------------------------------------------------ describe "#memberships" do + # Join the validity ranges of indirect memberships. + # + # @group1 + # |------- @subgroup1 -----| <--- past membership + # |------- @subgroup2 --- @user1 + # + # First, user1 joins subgroup1, then moves to subgroup2. + # + # |-----------| first indirect membership in @group1 + # |--------- second indirect membership in @group2 + # |--------------------- joined indirect membership + # before do - @group = create( :group ) - @group.child_users << @user - @membership = UserGroupMembership.find_by( user: @user, group: @group ) - end - subject { @user.memberships } - it "should return an array of the user's memberships" do - subject.should == [ @membership ] - end - it "should be the same as UserGroupMembership.find_all_by_user" do - subject.should == UserGroupMembership.find_all_by_user( @user ) - end - it "should allow to chain other ActiveRelation scopes, like `only_valid`" do - subject.only_valid.should == [ @membership ] + @group1 = create :group, name: 'group1' + @subgroup1 = @group1.child_groups.create name: 'subgroup1' + @subgroup2 = @group1.child_groups.create name: 'subgroup2' + @user1 = create :user; @subgroup1 << @user1; @subgroup2 << @user1 + @time1 = 1.year.ago; @time2 = 6.months.ago; @time3 = 2.months.ago; @time4 = nil + Membership.where(user: @user1, group: @subgroup1).first.update_attributes valid_from: @time1, valid_to: @time2 + Membership.where(user: @user1, group: @subgroup2).first.update_attributes valid_from: @time3, valid_to: @time4 + + @membership1 = Membership.where(user: @user1, group: @group1).first + @submembership1 = Membership.where(user: @user1, group: @subgroup1).first # past membership + @submembership2 = Membership.where(user: @user1, group: @subgroup2).first + end + subject { @user1.memberships } + specify "prelims" do + @membership1.should be_kind_of Membership + @submembership2.should be_kind_of Membership + @submembership1.should be_kind_of Membership + end + it { should be_kind_of MembershipCollection } + it "should only include current memberships per default" do + binding.pry + subject.should include @membership1, @submembership2 + subject.count.should == 2 end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 7e9878d3b..873ad31fa 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -331,6 +331,7 @@ # I18n.default_locale = :de I18n.locale = :de + TEST_TIMEZONE = "Berlin" # Request Host From 5d7cda27bb26849d0a915a39b9f6b6b44304a0ae Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Fri, 2 Oct 2015 00:01:14 +0200 Subject: [PATCH 26/42] connected groups: User#memberships interface migrated. --- app/models/concerns/membership_persistence.rb | 2 +- app/models/concerns/membership_review.rb | 1 + app/models/membership_collection.rb | 1 - app/models/status_group_membership.rb | 379 +++++----- app/models/user.rb | 6 +- spec/features/corporate_vita_spec.rb | 1 - spec/models/status_group_membership_spec.rb | 701 +++++++++--------- .../validity_range_spec.rb | 293 -------- spec/models/user_group_membership_spec.rb | 428 ----------- spec/models/user_spec.rb | 33 +- 10 files changed, 562 insertions(+), 1283 deletions(-) delete mode 100644 spec/models/user_group_membership_mixins/validity_range_spec.rb delete mode 100644 spec/models/user_group_membership_spec.rb diff --git a/app/models/concerns/membership_persistence.rb b/app/models/concerns/membership_persistence.rb index c64c1f8a3..5e9894e8d 100644 --- a/app/models/concerns/membership_persistence.rb +++ b/app/models/concerns/membership_persistence.rb @@ -29,7 +29,7 @@ def save def save! raise 'Cannot save! Indirect memberships are non-persistent objects.' unless direct? write_attributes_to_dag_link - dag_link.save! + dag_link.changed? ? dag_link.save! : true end def update_attributes!(attrs = {}) diff --git a/app/models/concerns/membership_review.rb b/app/models/concerns/membership_review.rb index 0a45d94fd..a90af5f14 100644 --- a/app/models/concerns/membership_review.rb +++ b/app/models/concerns/membership_review.rb @@ -5,6 +5,7 @@ def needs_review? end def needs_review=(new_needs_review) + new_needs_review = false if new_needs_review == "false" direct? || raise('Only direct memberships can be reviewed.') dag_link.add_flag :needs_review if new_needs_review dag_link.remove_flag :needs_review if not new_needs_review diff --git a/app/models/membership_collection.rb b/app/models/membership_collection.rb index 0bc58067d..ccbb94665 100644 --- a/app/models/membership_collection.rb +++ b/app/models/membership_collection.rb @@ -80,7 +80,6 @@ def groups delegate :map, :collect, :select, :each, to: :to_a def include?(*other_memberships) - binding.pry to_a.collect { |m| [m.group.id, m.user.id, m.valid_from, m.valid_to] } .include?(*other_memberships.collect { |m| [m.group.id, m.user.id, m.valid_from, m.valid_to] }) end diff --git a/app/models/status_group_membership.rb b/app/models/status_group_membership.rb index c2ac216a8..9a150a15c 100644 --- a/app/models/status_group_membership.rb +++ b/app/models/status_group_membership.rb @@ -1,189 +1,190 @@ -# -*- coding: utf-8 -*- -# -# This class represents the membership of a user in a status group, i.e. a subgroup of a corporation -# representing a member status, e.g. the subgroup 'guests' or 'presidents'. -# -class StatusGroupMembership < UserGroupMembership - - # Status Group Memberships do have more properties than regular memberships; - # those new properties are, e.g., shown in the corporate_vita. - # Since rails apparently does not support Multi Table Inheritance, - # this associated model takes the additional properties. - # - has_one :status_group_membership_info, foreign_key: 'membership_id', inverse_of: :membership #, autosave: true - - delegate( :promoted_by_workflow, :promoted_by_workflow=, - :promoted_on_event, :promoted_on_event=, - :workflow, :workflow=, - :event, :event=, # alias methods - to: :find_or_create_status_group_membership_info ) - - attr_accessible :event_by_name if defined? attr_accessible - - # Alias Methods For Delegated Methods - # ========================================================================================== - - def create_event( params ) - find_or_create_status_group_membership_info.create_promoted_on_event( params ) - end - - # Access the event (promoted_on_event) by its name, since this is the way - # most likely done by a user interface. - # - # If a new event is created, assign the corporation associated with this status group - # as the group of the event. - # - def event_by_name - self.event.name if self.event - end - def event_by_name=( event_name ) - if event_name.present? - if Event.find_by_name( event_name ) - self.event = Event.find_by_name( event_name ) - else - self.create_event( name: event_name ) - self.event.group ||= self.corporation if self.corporation - self.event.start_at = self.created_at - self.event.save - end - else - self.event = nil - end - end - - - # Creator - # ========================================================================================== - - def self.create( params ) - super( params ).becomes StatusGroupMembership - end - - - # Finder Methods - # ========================================================================================== - - # Returns all memberships in status groups that belong to the given corporation. - # - # corporation A - # |------------- status group 1 - # | |-------- user 1 - # | |-------- user 2 - # |------------- status group 2 - # |-------- user 3 - # - # The method therefore will return all memberships of subgroups of the corporation. - # - def self.find_all_by_corporation( corporation ) - raise 'Expect parameter to be a Corporation' unless corporation.kind_of? Corporation - status_groups = corporation.status_groups - status_group_ids = status_groups.collect { |group| group.id } - links = self - .where(:descendant_type => "User") - .where(:ancestor_type => "Group") - .where(:ancestor_id => status_group_ids) - .order('valid_from') - return links - end - - # Returns all memberships of the given user in status groups. - # - def self.find_all_by_user( user ) - raise 'Expect parameter to be a User' unless user.kind_of? User - status_groups = user.status_groups(with_invalid: true) - status_group_ids = status_groups.collect { |group| group.id } - links = self - .where(:descendant_type => "User") - .where(:descendant_id => user.id) - .where(:ancestor_type => "Group") - .where(:ancestor_id => status_group_ids) - .order('valid_from') - return links - end - - # Returns all memberships of the given user in the given corporation. - # - def self.find_all_by_user_and_corporation( user, corporation ) - raise 'Expect parameter to be a User' unless user.kind_of? User - status_groups = user.status_groups(with_invalid: true) - status_groups &= corporation.status_groups - status_group_ids = status_groups.collect { |group| group.id } - links = self - .where(:descendant_type => "User") - .where(:descendant_id => user.id) - .where(:ancestor_type => "Group") - .where(:ancestor_id => status_group_ids) - .order('valid_from') - return links - end - - # This method overrides the default finder method in order to make - # sure the returned object is of the StatusGroupMembership type. - # - def self.find_by_user_and_group( user, group ) - self - .where(ancestor_id: group.id, ancestor_type: 'Group') - .where(descendant_id: user.id, descendant_type: 'User') - .limit(1) - .first - - # The #becomes method won't work here. - #membership = super( user, group ) - #membership ? StatusGroupMembership.with_invalid.find(membership.id) : nil - end - - - # Save Method - # ========================================================================================== - - # Since several important attributes of this model are delegated, it is likely to change - # a delegated attribute without changing a direct attribute. For example: - # - # membership.workflow = some_workflow # workflow is delegated - # membership.changed? # => false - # membership.status_group_membership_info.changed? # => true - # membership.save - # - # The regular `save` method would fail, because there are `no changes` to the membership - # itself. - # - # To circumvent this, this save method first saves the delegate model if necessary and - # then calls the regular `save` method. - # - def save(*args) - save_status_group_membership_info_if_changed - if changed? - return super(*args) - else - return true - end - end - - def update_attributes( attributes, options = {} ) - self.assign_attributes( attributes, options ) - save - end - - - # Callback Methods for the Delegation to status_group_membership_info - # ========================================================================================== - - private - - def find_or_create_status_group_membership_info - status_group_membership_info || create_status_group_membership_info - end - - # When .save is called on this instance, but only the associated object has changed through - # the delegated methods, this instance is not marked as changed. As a result, any call of - # .save will fail. - # - # This method compensates for the missing automatism. - # - def save_status_group_membership_info_if_changed - find_or_create_status_group_membership_info.promoted_by_workflow.try(:save) - find_or_create_status_group_membership_info.promoted_on_event.try(:save) - find_or_create_status_group_membership_info.save - end - -end +# # -*- coding: utf-8 -*- +# # +# # This class represents the membership of a user in a status group, i.e. a subgroup of a corporation +# # representing a member status, e.g. the subgroup 'guests' or 'presidents'. +# # +# class StatusGroupMembership < UserGroupMembership +# +# # Status Group Memberships do have more properties than regular memberships; +# # those new properties are, e.g., shown in the corporate_vita. +# # Since rails apparently does not support Multi Table Inheritance, +# # this associated model takes the additional properties. +# # +# has_one :status_group_membership_info, foreign_key: 'membership_id', inverse_of: :membership #, autosave: true +# +# delegate( :promoted_by_workflow, :promoted_by_workflow=, +# :promoted_on_event, :promoted_on_event=, +# :workflow, :workflow=, +# :event, :event=, # alias methods +# to: :find_or_create_status_group_membership_info ) +# +# attr_accessible :event_by_name if defined? attr_accessible +# +# # Alias Methods For Delegated Methods +# # ========================================================================================== +# +# def create_event( params ) +# find_or_create_status_group_membership_info.create_promoted_on_event( params ) +# end +# +# # Access the event (promoted_on_event) by its name, since this is the way +# # most likely done by a user interface. +# # +# # If a new event is created, assign the corporation associated with this status group +# # as the group of the event. +# # +# def event_by_name +# self.event.name if self.event +# end +# def event_by_name=( event_name ) +# if event_name.present? +# if Event.find_by_name( event_name ) +# self.event = Event.find_by_name( event_name ) +# else +# self.create_event( name: event_name ) +# self.event.group ||= self.corporation if self.corporation +# self.event.start_at = self.created_at +# self.event.save +# end +# else +# self.event = nil +# end +# end +# +# +# # Creator +# # ========================================================================================== +# +# def self.create( params ) +# super( params ).becomes StatusGroupMembership +# end +# +# +# # Finder Methods +# # ========================================================================================== +# +# # Returns all memberships in status groups that belong to the given corporation. +# # +# # corporation A +# # |------------- status group 1 +# # | |-------- user 1 +# # | |-------- user 2 +# # |------------- status group 2 +# # |-------- user 3 +# # +# # The method therefore will return all memberships of subgroups of the corporation. +# # +# def self.find_all_by_corporation( corporation ) +# raise 'Expect parameter to be a Corporation' unless corporation.kind_of? Corporation +# status_groups = corporation.status_groups +# status_group_ids = status_groups.collect { |group| group.id } +# links = self +# .where(:descendant_type => "User") +# .where(:ancestor_type => "Group") +# .where(:ancestor_id => status_group_ids) +# .order('valid_from') +# return links +# end +# +# # Returns all memberships of the given user in status groups. +# # +# def self.find_all_by_user( user ) +# raise 'Expect parameter to be a User' unless user.kind_of? User +# status_groups = user.status_groups(with_invalid: true) +# status_group_ids = status_groups.collect { |group| group.id } +# links = self +# .where(:descendant_type => "User") +# .where(:descendant_id => user.id) +# .where(:ancestor_type => "Group") +# .where(:ancestor_id => status_group_ids) +# .order('valid_from') +# return links +# end +# +# # Returns all memberships of the given user in the given corporation. +# # +# def self.find_all_by_user_and_corporation( user, corporation ) +# raise 'Expect parameter to be a User' unless user.kind_of? User +# status_groups = user.status_groups(with_invalid: true) +# status_groups &= corporation.status_groups +# status_group_ids = status_groups.collect { |group| group.id } +# links = self +# .where(:descendant_type => "User") +# .where(:descendant_id => user.id) +# .where(:ancestor_type => "Group") +# .where(:ancestor_id => status_group_ids) +# .order('valid_from') +# return links +# end +# +# # This method overrides the default finder method in order to make +# # sure the returned object is of the StatusGroupMembership type. +# # +# def self.find_by_user_and_group( user, group ) +# self +# .where(ancestor_id: group.id, ancestor_type: 'Group') +# .where(descendant_id: user.id, descendant_type: 'User') +# .limit(1) +# .first +# +# # The #becomes method won't work here. +# #membership = super( user, group ) +# #membership ? StatusGroupMembership.with_invalid.find(membership.id) : nil +# end +# +# +# # Save Method +# # ========================================================================================== +# +# # Since several important attributes of this model are delegated, it is likely to change +# # a delegated attribute without changing a direct attribute. For example: +# # +# # membership.workflow = some_workflow # workflow is delegated +# # membership.changed? # => false +# # membership.status_group_membership_info.changed? # => true +# # membership.save +# # +# # The regular `save` method would fail, because there are `no changes` to the membership +# # itself. +# # +# # To circumvent this, this save method first saves the delegate model if necessary and +# # then calls the regular `save` method. +# # +# def save(*args) +# save_status_group_membership_info_if_changed +# if changed? +# return super(*args) +# else +# return true +# end +# end +# +# def update_attributes( attributes, options = {} ) +# self.assign_attributes( attributes, options ) +# save +# end +# +# +# # Callback Methods for the Delegation to status_group_membership_info +# # ========================================================================================== +# +# private +# +# def find_or_create_status_group_membership_info +# status_group_membership_info || create_status_group_membership_info +# end +# +# # When .save is called on this instance, but only the associated object has changed through +# # the delegated methods, this instance is not marked as changed. As a result, any call of +# # .save will fail. +# # +# # This method compensates for the missing automatism. +# # +# def save_status_group_membership_info_if_changed +# find_or_create_status_group_membership_info.promoted_by_workflow.try(:save) +# find_or_create_status_group_membership_info.promoted_on_event.try(:save) +# find_or_create_status_group_membership_info.save +# end +# +# end +# \ No newline at end of file diff --git a/app/models/user.rb b/app/models/user.rb index f5f94762a..6f95aaf34 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -471,13 +471,13 @@ def status_groups(options = {}) def status_group_memberships self.status_groups.collect do |group| - StatusGroupMembership.find_by_user_and_group( self, group ) + self.memberships.where(group: group).first end end def current_status_membership_in( corporation ) if status_group = current_status_group_in(corporation) - StatusGroupMembership.find_by_user_and_group(self, status_group) + self.memberships.where(group: status_group).first end end @@ -651,7 +651,7 @@ def self.find_all_non_hidden # user.group_flags.include? 'hidden_users' # def group_flags - cached { self.groups.collect { |g| f.flags }.flatten } + cached { self.groups.collect { |g| g.flags_to_syms }.flatten } end diff --git a/spec/features/corporate_vita_spec.rb b/spec/features/corporate_vita_spec.rb index b875c701a..8ff649e57 100644 --- a/spec/features/corporate_vita_spec.rb +++ b/spec/features/corporate_vita_spec.rb @@ -160,7 +160,6 @@ @valid_from_formatted = I18n.localize @membership.valid_from.in_time_zone(TEST_TIMEZONE).to_date page.should have_content @valid_from_formatted - save_and_open_page # activate inplace editing of the date_field first('.best_in_place.status_group_date_of_joining').click diff --git a/spec/models/status_group_membership_spec.rb b/spec/models/status_group_membership_spec.rb index 1d7fced0d..128d72838 100644 --- a/spec/models/status_group_membership_spec.rb +++ b/spec/models/status_group_membership_spec.rb @@ -1,350 +1,351 @@ -require 'spec_helper' - -describe StatusGroupMembership do - - # Alias Methods for Delegated Methods - # ========================================================================================== - - describe "#promoted_by_workflow" do - before do - @workflow = create( :workflow ) - @membership = create( :status_group_membership ) - end - subject { @membership.promoted_by_workflow } - describe "if one has been assigned" do - before do - @membership.promoted_by_workflow = @workflow - end - it "should return the workflow that has been associated" do - subject.should == @workflow - end - it "should persist" do - @membership.save - @reloaded_membership = StatusGroupMembership.find( @membership.id ) - @reloaded_membership.promoted_by_workflow.should == @workflow - @reloaded_membership = StatusGroupMembership - .find_by_user_and_group( @membership.user, @membership.group ) - @reloaded_membership.promoted_by_workflow.should == @workflow - end - it "should be an alias of #workflow" do - subject.should == @membership.workflow - end - end - describe "if none has been assigned" do - it { should == nil } - end - end - - describe "#promoted_on_event" do - before do - @event = create( :event ) - @membership = create( :status_group_membership ) - end - subject { @membership.promoted_on_event } - describe "if one has been assigned" do - before { @membership.promoted_on_event = @event } - it { should == @event } - it "should persist" do - @membership.save - @reloaded_membership = StatusGroupMembership.find( @membership.id ) - @reloaded_membership.promoted_on_event.should == @event - @reloaded_membership = StatusGroupMembership - .find_by_user_and_group( @membership.user, @membership.group ) - @reloaded_membership.promoted_on_event.should == @event - end - it "should be an alias of #event" do - subject.should == @membership.event - end - end - describe "if none has been assigned" do - it { should == nil } - end - end - - describe "#event_by_name" do - before do - @membership = create( :status_group_membership ) - end - subject { @membership.event_by_name } - describe "for existing event" do - before do - @event = create( :event ) - @membership.event = @event - end - it { should == @event.name } - end - describe "if no event is assigned" do - it { should == nil } - end - end - describe "#event_by_name=" do - before do - @membership = create( :status_group_membership ) - end - describe "for an existing event" do - before { @event = create( :event ) } - subject { @membership.event_by_name = @event.name } - it "should assign the event" do - @membership.event.should == nil - subject - @membership.event.should == @event - end - end - describe "for a new event" do - subject { @membership.event_by_name = "A New Event" } - it "should create the event" do - @membership.event.should == nil - subject - @membership.event.name.should == "A New Event" - end - it "should persist" do - subject - @membership.save - @reloaded_membership = StatusGroupMembership.find( @membership.id ) - @reloaded_membership.event.should == @membership.event - @reloaded_membership.event.name.should == "A New Event" - end - it "should copy the date from the membership" do - subject - @membership.event.start_at.to_date.should == @membership.created_at.to_date - end - describe "for the membership having a corporation" do - before do - @corporation = create( :corporation ) - @corporation.child_groups << @membership.group - end - it "should association the corporation with the new event" do - subject - @membership.event.group.becomes( Group ).should == @corporation.becomes( Group ) - end - end - end - describe "for an empty string" do - before do - @membership.event_by_name = "some prefilled event" - end - subject { @membership.event_by_name = "" } - it "should set the event to nil" do - subject - @membership.event.should == nil - end - end - end - - - - # Finder Methods - # ========================================================================================== - - class SomeCorporationDerivative < Corporation - # This is just a dummy. The main app could invent a class inherited from Corporation. - # Some methods need to work with them as well as with the original Corporation class. - end - - # @corporation - # |------- @intermediate_group - # |------------ @status_group - # | |--------- @user - # | - # |------------ @second_status_group - # - describe "Finder Methods: " do - before do - @corporation = create( :corporation ) - @intermediate_group = create( :group, name: "Not a Status Group" ) - @status_group = create( :group, name: "Status Group" ) - - @intermediate_group.parent_groups << @corporation - @status_group.parent_groups << @intermediate_group - @user = create( :user ) - @status_group.assign_user @user - - @membership = UserGroupMembership.find_by_user_and_group( @user, @status_group ) - .becomes( StatusGroupMembership ) - @intermediate_group_membership = UserGroupMembership - .find_by_user_and_group( @user, @intermediate_group ).becomes StatusGroupMembership - - @second_status_group = @intermediate_group.child_groups.create(name: "Second Status Group") - - @other_corporation = create(:corporation_with_status_groups) - @membership_in_other_corporation = @other_corporation.status_groups.first.assign_user(@user) - .becomes(StatusGroupMembership) - - @other_user = create(:user) - @membership_of_other_user = @status_group.assign_user(@other_user).becomes(StatusGroupMembership) - end - - describe ".find_all_by_corporation" do - subject { StatusGroupMembership.find_all_by_corporation( @corporation ) } - it "should be chainable, i.e. return an ActiveRecord::Relation object" do - subject.should be_kind_of ActiveRecord::Relation - end - it "should return the membership of the descendant_users in their status groups" do - subject.should include @membership - end - it "should work for corporation derivatives as well" do - @corporation_derivative = @corporation.becomes SomeCorporationDerivative - expect { StatusGroupMembership.find_all_by_corporation( @corporation_derivative ) } - .not_to raise_error - end - it "should not return memberships in intermediate groups" do - # this behavior might be changed by the main app. - subject.should_not include @intermediate_group_membership - end - end - - describe ".find_all_by_user" do - subject { StatusGroupMembership.find_all_by_user( @user ) } - it "should be chainable, i.e. return an ActiveRecord::Relation object" do - subject.should be_kind_of ActiveRecord::Relation - end - it "should return the memberships of the user in his status groups" do - subject.should include @membership - end - it "should not list memberships of the user in non-status groups" do - @non_status_membership = UserGroupMembership - .find_by_user_and_group( @user, @corporation ) - subject.should_not include @non_status_membership - end - it "should not return memberships in intermediate groups" do - # this behavior might be changed by the main app. - subject.should_not include @intermediate_group_membership - end - it "should return current memberships, but not expired memberships" do - subject.should include @membership - @membership.invalidate at: 2.minutes.ago - StatusGroupMembership.find_all_by_user( @user ).should_not include @membership - end - end - - describe ".find_all_by_user.now" do - subject { StatusGroupMembership.find_all_by_user( @user ).now } - it "should return current memberships, but not expired memberships" do - subject.should include @membership - @membership.invalidate at: 2.minutes.ago - StatusGroupMembership.find_all_by_user( @user ).now.should_not include @membership - end - end - - describe ".find_all_by_user.now_and_in_the_past" do - subject { StatusGroupMembership.find_all_by_user( @user ).now_and_in_the_past } - it "should return current memberships and expired ones" do - subject.should include @membership - @membership.invalidate at: 2.minutes.ago - StatusGroupMembership.find_all_by_user( @user ).now_and_in_the_past - .should include @membership - end - end - - describe ".find_all_by_user.in_the_past" do - subject { StatusGroupMembership.find_all_by_user( @user ).in_the_past } - it "should return only expired memberships" do - subject.should_not include @membership - @membership.invalidate at: 2.minutes.ago - StatusGroupMembership.find_all_by_user( @user ).in_the_past - .should include @membership - end - end - - describe ".find_all_by_user_and_corporation" do - subject { StatusGroupMembership.find_all_by_user_and_corporation( @user, @corporation ) } - it "should return the memberships of the user in the status groups of the corporation" do - subject.should include @membership - end - it "should not return memberships in other corporations" do - subject.should_not include @membership_in_other_corporation - end - it "should not return memberships of other users" do - subject.should_not include @membership_of_other_user - end - end - - # @corporation - # |------- @intermediate_group - # |------------ @status_group - # | |--------- (@user) - # | - # |------------ @second_status_group - # |--------- @user - # - describe ".now_and_in_the_past.find_all_by_user_and_corporation" do - before do - @membership.update_attribute(:valid_from, 1.year.ago) - @second_membership = @membership.move_to(@second_status_group, at: 20.days.ago) - .becomes(StatusGroupMembership) - end - subject { StatusGroupMembership.now_and_in_the_past.find_all_by_user_and_corporation(@user, @corporation) } - specify "prelims" do - @user.should be_kind_of User - @corporation.reload.should be_kind_of Corporation - @corporation.descendants.should include @intermediate_group, @status_group, @second_status_group, @user - @intermediate_group.reload.descendants.should include @status_group, @second_status_group, @user - @status_group.reload.descendants.should include @user - @second_status_group.reload.descendants.should include @user - @corporation.members.should include @user - @user.should be_member_of @corporation - @membership.valid_to.to_date.should == 20.days.ago.to_date - end - it { should include @second_membership } - it { should include @membership } - it { should_not include @intermediate_group_membership } - end - - end - - # Status Workflow in model layer (bug fix) - # ========================================================================================== - - describe "(status workflow scenario)" do - - before do - @user = create(:user) - @corporation = create(:corporation_with_status_groups) - @status_groups = @corporation.status_groups - - @first_status_group = @status_groups.first - @second_status_group = @status_groups.second - - @first_status_group.assign_user @user - - @first_promotion_workflow = create( :promotion_workflow, name: 'First Promotion', - :remove_from_group_id => @first_status_group.id, - :add_to_group_id => @second_status_group.id ) - @first_promotion_workflow.parent_groups << @first_status_group - end - - def status_groups_of_user_and_corporation - StatusGroupMembership.now_and_in_the_past.find_all_by_user_and_corporation(@user, @corporation) - end - def first_status_group_membership - StatusGroupMembership.with_invalid.find_by_user_and_group(@user, @first_status_group) - end - def second_status_group_membership - StatusGroupMembership.with_invalid.find_by_user_and_group(@user, @second_status_group) - end - - describe "prelims" do - specify "first_status_group_membership should find the membership even if invalidated" do - first_status_group_membership.should_not == nil - first_status_group_membership.invalidate at: 1.hour.ago - first_status_group_membership.should_not == nil - end - end - - describe "executing the first promotion workflow" do - subject { @first_promotion_workflow.execute(user_id: @user.id); @user.reload } - it "should add the second status group to the user's status groups" do - status_groups_of_user_and_corporation.should_not include second_status_group_membership - subject - status_groups_of_user_and_corporation.should include second_status_group_membership - end - it "should not remove the first status group from the user's status groups" do - status_groups_of_user_and_corporation.should include first_status_group_membership - subject - status_groups_of_user_and_corporation.should include first_status_group_membership - end - end - - end - -end +# require 'spec_helper' +# +# describe StatusGroupMembership do +# +# # Alias Methods for Delegated Methods +# # ========================================================================================== +# +# describe "#promoted_by_workflow" do +# before do +# @workflow = create( :workflow ) +# @membership = create( :status_group_membership ) +# end +# subject { @membership.promoted_by_workflow } +# describe "if one has been assigned" do +# before do +# @membership.promoted_by_workflow = @workflow +# end +# it "should return the workflow that has been associated" do +# subject.should == @workflow +# end +# it "should persist" do +# @membership.save +# @reloaded_membership = StatusGroupMembership.find( @membership.id ) +# @reloaded_membership.promoted_by_workflow.should == @workflow +# @reloaded_membership = StatusGroupMembership +# .find_by_user_and_group( @membership.user, @membership.group ) +# @reloaded_membership.promoted_by_workflow.should == @workflow +# end +# it "should be an alias of #workflow" do +# subject.should == @membership.workflow +# end +# end +# describe "if none has been assigned" do +# it { should == nil } +# end +# end +# +# describe "#promoted_on_event" do +# before do +# @event = create( :event ) +# @membership = create( :status_group_membership ) +# end +# subject { @membership.promoted_on_event } +# describe "if one has been assigned" do +# before { @membership.promoted_on_event = @event } +# it { should == @event } +# it "should persist" do +# @membership.save +# @reloaded_membership = StatusGroupMembership.find( @membership.id ) +# @reloaded_membership.promoted_on_event.should == @event +# @reloaded_membership = StatusGroupMembership +# .find_by_user_and_group( @membership.user, @membership.group ) +# @reloaded_membership.promoted_on_event.should == @event +# end +# it "should be an alias of #event" do +# subject.should == @membership.event +# end +# end +# describe "if none has been assigned" do +# it { should == nil } +# end +# end +# +# describe "#event_by_name" do +# before do +# @membership = create( :status_group_membership ) +# end +# subject { @membership.event_by_name } +# describe "for existing event" do +# before do +# @event = create( :event ) +# @membership.event = @event +# end +# it { should == @event.name } +# end +# describe "if no event is assigned" do +# it { should == nil } +# end +# end +# describe "#event_by_name=" do +# before do +# @membership = create( :status_group_membership ) +# end +# describe "for an existing event" do +# before { @event = create( :event ) } +# subject { @membership.event_by_name = @event.name } +# it "should assign the event" do +# @membership.event.should == nil +# subject +# @membership.event.should == @event +# end +# end +# describe "for a new event" do +# subject { @membership.event_by_name = "A New Event" } +# it "should create the event" do +# @membership.event.should == nil +# subject +# @membership.event.name.should == "A New Event" +# end +# it "should persist" do +# subject +# @membership.save +# @reloaded_membership = StatusGroupMembership.find( @membership.id ) +# @reloaded_membership.event.should == @membership.event +# @reloaded_membership.event.name.should == "A New Event" +# end +# it "should copy the date from the membership" do +# subject +# @membership.event.start_at.to_date.should == @membership.created_at.to_date +# end +# describe "for the membership having a corporation" do +# before do +# @corporation = create( :corporation ) +# @corporation.child_groups << @membership.group +# end +# it "should association the corporation with the new event" do +# subject +# @membership.event.group.becomes( Group ).should == @corporation.becomes( Group ) +# end +# end +# end +# describe "for an empty string" do +# before do +# @membership.event_by_name = "some prefilled event" +# end +# subject { @membership.event_by_name = "" } +# it "should set the event to nil" do +# subject +# @membership.event.should == nil +# end +# end +# end +# +# +# +# # Finder Methods +# # ========================================================================================== +# +# class SomeCorporationDerivative < Corporation +# # This is just a dummy. The main app could invent a class inherited from Corporation. +# # Some methods need to work with them as well as with the original Corporation class. +# end +# +# # @corporation +# # |------- @intermediate_group +# # |------------ @status_group +# # | |--------- @user +# # | +# # |------------ @second_status_group +# # +# describe "Finder Methods: " do +# before do +# @corporation = create( :corporation ) +# @intermediate_group = create( :group, name: "Not a Status Group" ) +# @status_group = create( :group, name: "Status Group" ) +# +# @intermediate_group.parent_groups << @corporation +# @status_group.parent_groups << @intermediate_group +# @user = create( :user ) +# @status_group.assign_user @user +# +# @membership = UserGroupMembership.find_by_user_and_group( @user, @status_group ) +# .becomes( StatusGroupMembership ) +# @intermediate_group_membership = UserGroupMembership +# .find_by_user_and_group( @user, @intermediate_group ).becomes StatusGroupMembership +# +# @second_status_group = @intermediate_group.child_groups.create(name: "Second Status Group") +# +# @other_corporation = create(:corporation_with_status_groups) +# @membership_in_other_corporation = @other_corporation.status_groups.first.assign_user(@user) +# .becomes(StatusGroupMembership) +# +# @other_user = create(:user) +# @membership_of_other_user = @status_group.assign_user(@other_user).becomes(StatusGroupMembership) +# end +# +# describe ".find_all_by_corporation" do +# subject { StatusGroupMembership.find_all_by_corporation( @corporation ) } +# it "should be chainable, i.e. return an ActiveRecord::Relation object" do +# subject.should be_kind_of ActiveRecord::Relation +# end +# it "should return the membership of the descendant_users in their status groups" do +# subject.should include @membership +# end +# it "should work for corporation derivatives as well" do +# @corporation_derivative = @corporation.becomes SomeCorporationDerivative +# expect { StatusGroupMembership.find_all_by_corporation( @corporation_derivative ) } +# .not_to raise_error +# end +# it "should not return memberships in intermediate groups" do +# # this behavior might be changed by the main app. +# subject.should_not include @intermediate_group_membership +# end +# end +# +# describe ".find_all_by_user" do +# subject { StatusGroupMembership.find_all_by_user( @user ) } +# it "should be chainable, i.e. return an ActiveRecord::Relation object" do +# subject.should be_kind_of ActiveRecord::Relation +# end +# it "should return the memberships of the user in his status groups" do +# subject.should include @membership +# end +# it "should not list memberships of the user in non-status groups" do +# @non_status_membership = UserGroupMembership +# .find_by_user_and_group( @user, @corporation ) +# subject.should_not include @non_status_membership +# end +# it "should not return memberships in intermediate groups" do +# # this behavior might be changed by the main app. +# subject.should_not include @intermediate_group_membership +# end +# it "should return current memberships, but not expired memberships" do +# subject.should include @membership +# @membership.invalidate at: 2.minutes.ago +# StatusGroupMembership.find_all_by_user( @user ).should_not include @membership +# end +# end +# +# describe ".find_all_by_user.now" do +# subject { StatusGroupMembership.find_all_by_user( @user ).now } +# it "should return current memberships, but not expired memberships" do +# subject.should include @membership +# @membership.invalidate at: 2.minutes.ago +# StatusGroupMembership.find_all_by_user( @user ).now.should_not include @membership +# end +# end +# +# describe ".find_all_by_user.now_and_in_the_past" do +# subject { StatusGroupMembership.find_all_by_user( @user ).now_and_in_the_past } +# it "should return current memberships and expired ones" do +# subject.should include @membership +# @membership.invalidate at: 2.minutes.ago +# StatusGroupMembership.find_all_by_user( @user ).now_and_in_the_past +# .should include @membership +# end +# end +# +# describe ".find_all_by_user.in_the_past" do +# subject { StatusGroupMembership.find_all_by_user( @user ).in_the_past } +# it "should return only expired memberships" do +# subject.should_not include @membership +# @membership.invalidate at: 2.minutes.ago +# StatusGroupMembership.find_all_by_user( @user ).in_the_past +# .should include @membership +# end +# end +# +# describe ".find_all_by_user_and_corporation" do +# subject { StatusGroupMembership.find_all_by_user_and_corporation( @user, @corporation ) } +# it "should return the memberships of the user in the status groups of the corporation" do +# subject.should include @membership +# end +# it "should not return memberships in other corporations" do +# subject.should_not include @membership_in_other_corporation +# end +# it "should not return memberships of other users" do +# subject.should_not include @membership_of_other_user +# end +# end +# +# # @corporation +# # |------- @intermediate_group +# # |------------ @status_group +# # | |--------- (@user) +# # | +# # |------------ @second_status_group +# # |--------- @user +# # +# describe ".now_and_in_the_past.find_all_by_user_and_corporation" do +# before do +# @membership.update_attribute(:valid_from, 1.year.ago) +# @second_membership = @membership.move_to(@second_status_group, at: 20.days.ago) +# .becomes(StatusGroupMembership) +# end +# subject { StatusGroupMembership.now_and_in_the_past.find_all_by_user_and_corporation(@user, @corporation) } +# specify "prelims" do +# @user.should be_kind_of User +# @corporation.reload.should be_kind_of Corporation +# @corporation.descendants.should include @intermediate_group, @status_group, @second_status_group, @user +# @intermediate_group.reload.descendants.should include @status_group, @second_status_group, @user +# @status_group.reload.descendants.should include @user +# @second_status_group.reload.descendants.should include @user +# @corporation.members.should include @user +# @user.should be_member_of @corporation +# @membership.valid_to.to_date.should == 20.days.ago.to_date +# end +# it { should include @second_membership } +# it { should include @membership } +# it { should_not include @intermediate_group_membership } +# end +# +# end +# +# # Status Workflow in model layer (bug fix) +# # ========================================================================================== +# +# describe "(status workflow scenario)" do +# +# before do +# @user = create(:user) +# @corporation = create(:corporation_with_status_groups) +# @status_groups = @corporation.status_groups +# +# @first_status_group = @status_groups.first +# @second_status_group = @status_groups.second +# +# @first_status_group.assign_user @user +# +# @first_promotion_workflow = create( :promotion_workflow, name: 'First Promotion', +# :remove_from_group_id => @first_status_group.id, +# :add_to_group_id => @second_status_group.id ) +# @first_promotion_workflow.parent_groups << @first_status_group +# end +# +# def status_groups_of_user_and_corporation +# StatusGroupMembership.now_and_in_the_past.find_all_by_user_and_corporation(@user, @corporation) +# end +# def first_status_group_membership +# StatusGroupMembership.with_invalid.find_by_user_and_group(@user, @first_status_group) +# end +# def second_status_group_membership +# StatusGroupMembership.with_invalid.find_by_user_and_group(@user, @second_status_group) +# end +# +# describe "prelims" do +# specify "first_status_group_membership should find the membership even if invalidated" do +# first_status_group_membership.should_not == nil +# first_status_group_membership.invalidate at: 1.hour.ago +# first_status_group_membership.should_not == nil +# end +# end +# +# describe "executing the first promotion workflow" do +# subject { @first_promotion_workflow.execute(user_id: @user.id); @user.reload } +# it "should add the second status group to the user's status groups" do +# status_groups_of_user_and_corporation.should_not include second_status_group_membership +# subject +# status_groups_of_user_and_corporation.should include second_status_group_membership +# end +# it "should not remove the first status group from the user's status groups" do +# status_groups_of_user_and_corporation.should include first_status_group_membership +# subject +# status_groups_of_user_and_corporation.should include first_status_group_membership +# end +# end +# +# end +# +# end +# \ No newline at end of file diff --git a/spec/models/user_group_membership_mixins/validity_range_spec.rb b/spec/models/user_group_membership_mixins/validity_range_spec.rb deleted file mode 100644 index f4225d7c0..000000000 --- a/spec/models/user_group_membership_mixins/validity_range_spec.rb +++ /dev/null @@ -1,293 +0,0 @@ -require 'spec_helper' - -describe UserGroupMembershipMixins::ValidityRange do - - before do - @user = create(:user) - @group = create(:group) - @membership = UserGroupMembership.create(user: @user, group: @group) - @membership.reload - end - - specify "preliminaries" do - @membership.should_not be_changed - @membership.id.should be_kind_of Integer - @membership.should be_kind_of UserGroupMembership - end - - describe "#valid_from" do - subject { @membership.valid_from } - it { should be_kind_of Time } - it "should be set to the created_at date by default" do - subject.to_i.should > @membership.created_at.to_i-2 - subject.to_i.should < @membership.created_at.to_i+2 - end - end - describe "#valid_to" do - subject { @membership.valid_to } - describe "being unset" do - it { should == nil } - end - describe "being set" do - before { @membership.valid_to = 1.hour.ago } - it { should be_kind_of Time } - end - end - - describe "#valid_from_localized_date" do - subject { @membership.valid_from_localized_date } - describe "if no valid_from given" do - before { @membership.valid_from = nil } - it { should == "" } - end - describe "if a datetime given" do - before do - @time = "1.1.2013 12:30 UTC".to_datetime - @membership.valid_from = @time - end - it { should == "01.01.2013" } - end - end - describe "#valid_from_localized_date=" do - describe "setting a date string" do - subject { @membership.valid_from_localized_date = "1.1.2013" } - it "should set the correct date" do - subject - @membership.valid_from.to_date.should == "1.1.2013".to_date - end - end - describe "setting an empty string" do - subject { @membership.valid_from_localized_date = "" } - it "should set valid_from to nil" do - subject - @membership.valid_from.should == nil - end - end - describe "setting an invalid date" do - subject { @membership.valid_from_localized_date = "FOO BAR" } - it "should raise an error" do - expect { subject }.to raise_error - end - end - end - - describe "#make_invalid" do - describe "with time argument" do - before { @time = 1.hour.ago } - subject { @membership.make_invalid(@time) } - it "should set the valid_to argument to the given time" do - @membership.valid_to.should == nil - subject - @membership.valid_to.should == @time - end - it "should mark the membership as invalid" do - @membership.currently_valid?.should == true - subject - @membership.currently_valid?.should == false - end - it "should return the membership" do - subject.should == @membership - end - describe "with 'at: time' argument" do - subject { @membership.make_invalid at: @time } - it "should set the valid_to argument to the given time" do - @membership.valid_to.should == nil - subject - @membership.valid_to.should == @time - end - end - end - describe "without argument" do - subject { @membership.make_invalid } - it "should set the end of the validity to the current time" do - @membership.valid_to.should == nil - subject - @membership.valid_to.to_i.should > Time.zone.now.to_i-2 - @membership.valid_to.to_i.should < Time.zone.now.to_i+2 - end - end - end - - describe "#invalidate" do - describe "with time argument" do - before { @time = 1.hour.ago } - subject { @membership.invalidate(@time) } - it "should set the valid_to argument to the given time" do - @membership.valid_to.should == nil - subject - @membership.valid_to.should == @time - end - it "should mark the membership as invalid" do - @membership.currently_valid?.should == true - subject - @membership.currently_valid?.should == false - end - it "should return the membership" do - subject.should == @membership - end - describe "with 'at: time' argument" do - subject { @membership.invalidate at: @time } - it "should set the valid_to argument to the given time" do - @membership.valid_to.should == nil - subject - @membership.valid_to.should == @time - end - end - end - describe "without argument" do - subject { @membership.invalidate } - it "should set the end of the validity to the current time" do - @membership.valid_to.should == nil - subject - @membership.valid_to.to_i.should == Time.zone.now.to_i - end - end - end - - describe "#currently_valid?" do - subject { @membership.currently_valid? } - it "should check whether the membership is valid in terms of the validity range at present time" do - @membership.currently_valid?.should == true - @membership.invalidate - @membership.currently_valid?.should == false - end - end - describe "#valid_at?(time)" do - before do - @time = 1.hour.ago - @membership.update_attribute(:valid_from, @time) - end - subject { @membership.valid_at? @time } - it "should check whether the membership is valid in terms of the validity range at the given time" do - @membership.valid_at?(@time).should == true - @membership.invalidate at: (@time - 1.hour) - @membership.valid_at?(@time).should == false - end - end - - describe "(temporal scopes)" do - before do - @valid_membership = @membership - @valid_membership.update_attribute(:valid_from, 2.hours.ago) - @group2 = create(:group) - @time = 1.hour.ago - @invalid_membership = UserGroupMembership.create(user: @user, group: @group2) - @invalid_membership.valid_from = 2.hours.ago - @invalid_membership.invalidate(@time) - @query = UserGroupMembership.find_all_by_user(@user) - end - - describe "#at_time" do - subject { @query.at_time(@time + 1.minute) } - it "should limit the search to match the validity range" do - subject.should include @valid_membership - subject.should_not include @invalid_membership - end - end - - describe "#only_valid" do - subject { @query.only_valid } - it "should return only memberships that are currently valid" do - subject.should include @valid_membership - subject.should_not include @invalid_membership - end - end - - describe "#only_invalid" do - subject { @query.only_invalid } - it "should return only memberships that are currently invalid" do - subject.should_not include @valid_membership - subject.should include @invalid_membership - end - end - - describe "#with_invalid" do - subject { @query.with_invalid } - it "should return both valid and invalid memberships" do - subject.should include @valid_membership - subject.should include @invalid_membership - end - end - - describe "(by default)" do - subject { @query } - it "should return only currently valid memberships" do - subject.should include @valid_membership - subject.should_not include @invalid_membership - end - end - - describe "#now" do - subject { @query.now } - it "should return only memberships that are currently valid" do - subject.should include @valid_membership - subject.should_not include @invalid_membership - end - end - describe "#in_the_past" do - subject { @query.in_the_past } - it "should return only memberships that are currently invalid" do - subject.should_not include @valid_membership - subject.should include @invalid_membership - end - end - describe "#now_and_in_the_past" do - subject { @query.now_and_in_the_past } - it "should return both valid and invalid memberships" do - subject.should include @valid_membership - subject.should include @invalid_membership - end - end - end - - describe "(validity range constraints)" do - # - # @time @now - # ====================================================> time - # |------------------------------------> @membership1 - # |-----------------------> @membership2 - # |-----------| @membership3 - # ----------------------------------------------------> @membership4 - # - # - before do - @user1 = create :user - @user2 = create :user - @user3 = create :user - @user4 = create :user - @time = 1.year.ago - @now = Time.zone.now - @membership1 = @group.assign_user @user1, at: @time - 1.day - @membership2 = @group.assign_user @user2, at: @time + 1.day - @membership3 = @group.assign_user @user3, at: @time + 1.day; @membership3.invalidate at: 1.month.ago - @membership4 = @group.assign_user @user4; @membership4.update_attribute(:valid_from, nil) - end - specify 'prelims' do - @membership4.valid_from.should == nil - @membership4.valid_to.should == nil - @group.memberships.last.id.should == @membership4.id - @group.memberships.last.valid_from.should == nil - end - describe ".now_and_in_the_past.started_after(time)" do - subject { @group.memberships.now_and_in_the_past.started_after(@time) } - it { should include @membership2 } - it { should include @membership3 } - it { should_not include @membership1 } - it { should_not include @membership4 } - end - describe ".started_after(time)" do - subject { @group.memberships.started_after(@time) } - it { should include @membership2 } - it { should_not include @membership3 } - it { should_not include @membership1 } - it { should_not include @membership4 } - end - describe "to_a.started_after(time)" do - subject { @group.memberships.to_a.started_after(@time) } - it { should include @membership2 } - it { should_not include @membership3 } - it { should_not include @membership1 } - it { should_not include @membership4 } - end - end -end diff --git a/spec/models/user_group_membership_spec.rb b/spec/models/user_group_membership_spec.rb deleted file mode 100644 index df0be5f79..000000000 --- a/spec/models/user_group_membership_spec.rb +++ /dev/null @@ -1,428 +0,0 @@ -require 'spec_helper' - -describe UserGroupMembership do - - before do - @group = Group.create( name: "Group 1" ) - @super_group = Group.create( name: "Parent Group of Groups 1 and 2" ) - @other_group = Group.create( name: "Group 2" ) - @group.parent_groups << @super_group - @other_group.parent_groups << @super_group - @other_user = create(:user) - @user = User.create( first_name: "John", last_name: "Doe", :alias => "j.doe" ) - end - - it "should allow to create example group and user and the group structure" do - @user.should_not == nil - @group.should_not == nil - @super_group.should_not == nil - end - - def create_membership - UserGroupMembership.create( user: @user, group: @group ) - end - - def find_membership - UserGroupMembership.find_by( user: @user, group: @group ) - end - - def find_membership_now_and_in_the_past - UserGroupMembership.find_all_by( user: @user, group: @group ).now_and_in_the_past.first - end - - def find_indirect_membership - UserGroupMembership.find_by( user: @user, group: @super_group ) - end - - def find_indirect_membership_now_and_in_the_past - UserGroupMembership.find_all_by( user: @user, group: @super_group ).now_and_in_the_past.first - end - - def create_other_membership - UserGroupMembership.create( user: @user, group: @other_group ) - end - - def create_another_membership - UserGroupMembership.create( user: @other_user, group: @group ) - end - - def find_other_membership - UserGroupMembership.find_by( user: @user, group: @other_group ) - end - - def find_other_membership_now_and_in_the_past - UserGroupMembership.find_all_by( user: @user, group: @other_group).now_and_in_the_past.first - end - - def create_memberships - create_membership - create_other_membership - create_another_membership - # the indirect membership is created implicitly, becuase @group and @super_group are already connected. - end - - # Creation Class Method - # ==================================================================================================== - - describe ".create" do - it "should create a link between parent and child" do - UserGroupMembership.create( user: @user, group: @group ) - @user.parents.should include( @group ) - end - it "should raise an error if argument is missing" do - expect { UserGroupMembership.create( user: @user ) }.to raise_error RuntimeError - expect { UserGroupMembership.create( group: @group ) }.to raise_error RuntimeError - end - it "should be able to identify a user by its 'user_title'" do - UserGroupMembership.create( user_title: @user.title, group_id: @group.id ) - @user.parents.should include @group - end - end - - # Finder Class Methods - # ==================================================================================================== - - describe "Finder Method" do - before { create_memberships } - - describe ".find_all_by" do - it "should find all memberships for a user" do - UserGroupMembership.find_all_by( user: @user ).should include( find_membership ) - UserGroupMembership.find_all_by( user: @user ).should include( find_indirect_membership ) - end - it "should find all memberships for a group" do - UserGroupMembership.find_all_by( group: @group ).should include( find_membership ) - end - it "should not find memberships that are invalid at the present time" do - find_membership.update_attribute(:valid_to, 1.hour.ago) - UserGroupMembership.find_all_by( user: @user ) - .should_not include( find_membership_now_and_in_the_past ) - UserGroupMembership.find_all_by( user: @user ) - .should include find_other_membership - end - it "should be able to identify users by 'user_title'" do - UserGroupMembership.find_all_by( user_title: @user.title ).each do |membership| - membership.user_id.should == @user.id - end - end - end - describe ".find_all_by.now_and_in_the_past" do - before { find_membership.make_invalid } - it "should find all memberships, including the ones that are invalid at the present time" do - UserGroupMembership.find_all_by( user: @user ).now_and_in_the_past - .should include( find_membership_now_and_in_the_past, find_indirect_membership, find_other_membership ) - end - end - - describe ".find_by" do - it "should be the same as .find_by_all.first" do - UserGroupMembership.find_by( user: @user, group: @group ).should == - UserGroupMembership.find_all_by( user: @user, group: @group ).first - end - end - - describe ".find_by_user_and_group" do - it "should find the right membership" do - UserGroupMembership.find_by_user_and_group( @user, @group ).should == find_membership - end - end - - describe ".find_all_by_user" do - it "should find the right memberships" do - UserGroupMembership.find_all_by_user( @user ).should include( find_membership ) - end - end - - describe ".find_all_by_group" do - it "should find the right memberships" do - UserGroupMembership.find_all_by_group( @group ).should include( find_membership ) - end - end - end - - describe "#== ( other_membership ), i.e. euality relation, " do - it "should return true if the two objects represent the same membership" do - membership = create_membership - same_membership = find_membership - membership.should == same_membership - end - end - - - # Access Methods to Associated User and Group - # ==================================================================================================== - - describe "Access Method to Assiciation" do - before { @membership = create_membership } - subject { @membership } - - describe "#user" do - its(:user) { should == @user } - end - describe "#user=" do - subject { @membership.user = @other_user } - it "should assign a user to the membership" do - @membership.user.should == @user - subject - @membership.user.should == @other_user - end - end - describe "#user_id" do - subject { @membership.user_id } - it { should == @user.id } - end - describe "#user_title" do - subject { @membership.user_title } - it { should == @user.title } - end - describe "#user_title=" do - subject { @membership.user_title = @other_user.title } - it "should assign the user matching the title to the membership" do - @membership.user.should == @user - subject - @membership.user.should == @other_user - end - end - describe "#group" do - its(:group) { should == @group } - end - describe "#group_id" do - subject { @membership.group_id } - it { should == @group.id } - end - end - - - # Associated Corporation - # ==================================================================================================== - - # corporation - # |-------- group - # |---( membership )---- user - # - describe "#corporation" do - describe "for the group having a corporation" do - before do - @corporation = create( :corporation ) - @group = @corporation.child_groups.create - @user = create( :user ) - @group.assign_user @user - end - subject { UserGroupMembership.find_by_user_and_group( @user, @group ).corporation } - it { should == @corporation } - end - describe "for the group not having a corporation" do - before do - @group = create( :group ) - @user = create( :user ) - @group.assign_user @user - end - subject { UserGroupMembership.find_by_user_and_group( @user, @group ).corporation } - it { should == nil } - end - describe "for the group being a corporation" do - before do - @corporation = create( :corporation ) - @user = create( :user ) - @corporation.assign_user @user - end - subject { UserGroupMembership.find_by_user_and_group( @user, @corporation ).corporation } - it { should == @corporation } - end - end - - - - # Access Methods to Associated Direct Memberships - # ==================================================================================================== - - describe "#direct_memberships" do - before {create_memberships } - describe "for a direct membership" do - subject { find_membership } - it "should include only itself (the direct membership)" do - subject.direct_memberships.should == [ subject ] - end - end - describe "for an indirect membership" do - subject { find_indirect_membership } - it "should include the direct membership" do - subject.direct_memberships.should include( find_membership ) - end - end - end - - describe "#direct_memberships_now_and_in_the_past" do - before { create_memberships } - it "should return an ActiveRecord::Relation, i.e. be chainable" do - find_membership.direct_memberships_now_and_in_the_past.kind_of?( ActiveRecord::Relation ).should be_true - end - # it "should be the same as #direct_memberships.now_and_in_the_past" do - # find_indirect_membership.direct_memberships_now_and_in_the_past.should == - # find_indirect_membership.direct_memberships.now_and_in_the_past - # end - describe "for a direct membership" do - it "should include itself (the direct membership)" do - find_membership.direct_memberships_now_and_in_the_past.should include( find_membership ) - end - end - describe "for an indirect membership" do - it "should include the direct membership" do - find_indirect_membership.direct_memberships_now_and_in_the_past.should include( find_membership ) - end - end - end - - describe "#direct_groups" do - before { create_memberships } - describe "for a direct membership" do - it "should return an array containing only the own group" do - find_membership.direct_groups.should == [ find_membership.group ] - end - end - describe "for an indirect membership" do - it "should return an array containing the direct group" do - find_indirect_membership.direct_groups.should == [ find_membership.group, find_other_membership.group ] - end - end - end - - - # Access Methods to Associated Indirect Memberships - # ==================================================================================================== - - describe "#indirect_memberships" do - before do - @membership = UserGroupMembership.create(user: @user, group: @group) - @indirect_membership = find_indirect_membership - end - subject { @membership.indirect_memberships } - it { should include find_indirect_membership } - it { should_not include find_membership } - describe "for invalidated memberships" do - before do - @membership.update_attribute(:valid_from, 2.hours.ago) - @membership.update_attribute(:valid_to, 1.hour.ago) - end - it "should still find the indirect memberships" do - subject.should include @indirect_membership - end - end - end - - - # More Tests for Indirect Memberships - # ==================================================================================================== - - describe "Indirect Membership" do - - before do - @sub_group = Group.create( name: "Sub Group" ) - @sub_group.parent_groups << @group - @user.parent_groups << @sub_group - @membership = UserGroupMembership.find_by_user_and_group( @user, @sub_group ) - @indirect_membership = UserGroupMembership.find_by_user_and_group( @user, @group ) - end - - subject { @indirect_membership } - - it "should have the same validity range (valid_from) as the direct membership" do - @indirect_membership.valid_from.should == @membership.valid_from - end - - it "should have the same validity range (valid_to) as the direct membership" do - @indirect_membership.valid_to.should == @membership.valid_to - end - - it "should also effect the direct membership on change of the valid_from date" do - new_time = 1.hour.ago - @membership.valid_from = new_time - @membership.save - @indirect_membership.valid_from.to_i.should == new_time.to_i - end - - it "should also effect the direct membership on change of the valid_to date" do - new_time = Time.current + 1.hour - @membership.invalidate - @membership.valid_to = new_time - @membership.save - @indirect_membership.reload - @indirect_membership.valid_to.to_i.should == new_time.to_i - end - - it "should be effected by the direct membership on change of the valid_from date" do - new_time = 1.hour.ago - @indirect_membership.valid_from = new_time - @indirect_membership.save - @membership.reload - @membership.valid_from.to_i.should == new_time.to_i - end - - it "should be effected by the direct membership on change of the valid_to date" do - new_time = Time.current + 1.hour - @membership.invalidate # need to archive the *direct* membership, ... - @indirect_membership.reload - @indirect_membership.valid_to = new_time # but can change the time of the *indirect*. - @indirect_membership.save - @membership.reload - @membership.valid_to.to_i.should == new_time.to_i - end - end - - # Methods to Change the Membership - # ==================================================================================================== - - describe "#move_to_group( group )" do - before do - create_membership - find_membership.move_to_group( @other_group ) - time_travel 2.seconds - end - it "should hide old direct membership" do - find_membership.should == nil - end - it "should create a new membership between the user and the given group" do - find_other_membership.should_not == nil - end - end - - - # Destroy - # ========================================================================================== - - describe "#destroy" do - describe "for nested structures (bug fix)" do - # - # @corporation - # |-------- @status_1 ---------------- @user | p - # |-------- @group_a | r - # | |------- @status_2 ---- @user | o - # | | m - # |-------- @group_b | o - # |------- @status_3 ---- @user | t - # V e - before do - @user = create(:user) - @corporation = create(:corporation) - @status_1 = @corporation.child_groups.create - @group_a = @corporation.child_groups.create - @status_2 = @group_a.child_groups.create - @group_b = @corporation.child_groups.create - @status_3 = @group_b.child_groups.create - @membership_1 = @status_1.assign_user @user, at: 1.year.ago - @membership_2 = @membership_1.promote_to @status_2, at: 10.minutes.ago - @membership_3 = @membership_2.promote_to @status_3, at: 2.minutes.ago - end - subject do - @user.parent_groups.each do |group| - UserGroupMembership.with_invalid.find_by_user_and_group(@user, group).destroy - end - end - it "should not raise an error (bug fix)" do - expect { subject }.not_to raise_error - end - end - end - - -end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index a9b65831e..27ab3cb4a 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -767,7 +767,7 @@ @subgroup = create( :group ); @subgroup.parent_groups << @corporationE @user.save - @first_membership_E = StatusGroupMembership.create( user: @user, group: @corporationE.status_groups.first ) + @first_membership_E = Membership.create(user: @user, group: @corporationE.status_groups.first) @user.parent_groups << @subgroup @user.reload end @@ -780,7 +780,7 @@ @user.cached(:corporations) wait_for_cache - first_membership_S = StatusGroupMembership.create( user: @user, group: @corporationS.status_groups.first ) + first_membership_S = Membership.create(user: @user, group: @corporationS.status_groups.first) first_membership_S.update_attributes(valid_from: "2010-05-01".to_datetime) @user.reload end @@ -791,7 +791,7 @@ @user.cached(:corporations) wait_for_cache - first_membership_H = StatusGroupMembership.create( user: @user, group: @corporationH.guests_parent ) + first_membership_H = Membership.create(user: @user, group: @corporationH.guests_parent) first_membership_H.update_attributes(valid_from: "2010-05-01".to_datetime) @user.reload end @@ -802,7 +802,7 @@ @user.cached(:corporations) former_group = @corporationE.child_groups.create former_group.add_flag :former_members_parent - second_membership_E = StatusGroupMembership.create( user: @user, group: former_group ) + second_membership_E = Membership.create(user: @user, group: former_group) second_membership_E.update_attributes(valid_from: "2014-05-01".to_datetime) @first_membership_E.update_attributes(valid_to: "2014-05-01".to_datetime) @user.reload @@ -819,7 +819,7 @@ @subgroup = create( :group ); @subgroup.parent_groups << @corporationE @user.save - @first_membership_E = StatusGroupMembership.create( user: @user, group: @corporationE.status_groups.first ) + @first_membership_E = Membership.create(user: @user, group: @corporationE.status_groups.first) @user.parent_groups << @subgroup @user.reload end @@ -831,7 +831,7 @@ end context "when user entered corporation S" do before do - first_membership_S = StatusGroupMembership.create( user: @user, group: @corporationS.status_groups.first ) + first_membership_S = Membership.create(user: @user, group: @corporationS.status_groups.first) first_membership_S.update_attributes(valid_from: "2010-05-01".to_datetime) @user.reload end @@ -839,7 +839,7 @@ end context "when user entered corporation H as guest" do before do - first_membership_H = StatusGroupMembership.create( user: @user, group: @corporationH.guests_parent ) + first_membership_H = Membership.create(user: @user, group: @corporationH.guests_parent) first_membership_H.update_attributes(valid_from: "2010-05-01".to_datetime) @user.reload end @@ -849,7 +849,7 @@ before do former_group = @corporationE.child_groups.create former_group.add_flag :former_members_parent - second_membership_E = StatusGroupMembership.create( user: @user, group: former_group ) + second_membership_E = Membership.create(user: @user, group: former_group) second_membership_E.update_attributes(valid_from: "2014-05-01".to_datetime) @first_membership_E.update_attributes(valid_to: "2014-05-01".to_datetime) @user.reload @@ -874,7 +874,7 @@ @subgroup = create( :group ); @subgroup.parent_groups << @corporationE @user.save - @first_membership_E = StatusGroupMembership.create( user: @user, group: @corporationE.status_groups.first ) + @first_membership_E = Membership.create(user: @user, group: @corporationE.status_groups.first) @user.parent_groups << @subgroup @user.reload end @@ -887,7 +887,7 @@ @user.cached(:current_corporations) wait_for_cache - first_membership_S = StatusGroupMembership.create( user: @user, group: @corporationS.status_groups.first ) + first_membership_S = Membership.create(user: @user, group: @corporationS.status_groups.first) first_membership_S.update_attributes(valid_from: "2010-05-01".to_datetime) @user.reload end @@ -896,7 +896,7 @@ context "when user entered corporation H as guest" do before do @user.cached(:current_corporations) - first_membership_H = StatusGroupMembership.create( user: @user, group: @corporationH.guests_parent ) + first_membership_H = Membership.create(user: @user, group: @corporationH.guests_parent) first_membership_H.update_attributes(valid_from: "2010-05-01".to_datetime) @user.reload end @@ -909,7 +909,7 @@ former_group = @corporationE.child_groups.create former_group.add_flag :former_members_parent - second_membership_E = StatusGroupMembership.create( user: @user, group: former_group ) + second_membership_E = Membership.create(user: @user, group: former_group) second_membership_E.update_attributes(valid_from: "2014-05-01".to_datetime) @first_membership_E.update_attributes(valid_to: "2014-05-01".to_datetime) @user.reload @@ -992,7 +992,7 @@ @corporation = create( :corporation_with_status_groups ) @status_group = @corporation.status_groups.first @status_group.assign_user @user - @status_group_membership = StatusGroupMembership.find_by_user_and_group(@user, @status_group) + @status_group_membership = Membership.where(user: @user, group: @status_group).first end subject { @user.current_status_membership_in(@corporation) } @@ -1027,7 +1027,7 @@ Membership.where(user: @user1, group: @subgroup1).first.update_attributes valid_from: @time1, valid_to: @time2 Membership.where(user: @user1, group: @subgroup2).first.update_attributes valid_from: @time3, valid_to: @time4 - @membership1 = Membership.where(user: @user1, group: @group1).first + @membership1 = Membership.where(user: @user1, group: @group1).last @submembership1 = Membership.where(user: @user1, group: @subgroup1).first # past membership @submembership2 = Membership.where(user: @user1, group: @subgroup2).first end @@ -1039,7 +1039,6 @@ end it { should be_kind_of MembershipCollection } it "should only include current memberships per default" do - binding.pry subject.should include @membership1, @submembership2 subject.count.should == 2 end @@ -1361,10 +1360,10 @@ subject { @user.group_flags } describe "for the user being hidden" do before { @user.hidden = true } - it { should include 'hidden_users' } + it { should include :hidden_users } end describe "for the user not being hidden" do - it { should_not include 'hidden_users' } + it { should_not include :hidden_users } end end From 423ac055409480e0f14f62171a35404bd8c191c8 Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Sat, 3 Oct 2015 12:50:02 +0200 Subject: [PATCH 27/42] connected groups: migrating Group#members interface to the new mechanism. `Group#members` now returns a `MemberCollection`. --- app/controllers/group_members_controller.rb | 4 +- app/controllers/groups_controller.rb | 3 +- app/models/ability.rb | 4 +- .../concerns/group_member_assignment.rb | 67 ++++ app/models/concerns/group_memberships.rb | 65 ++++ app/models/corporation.rb | 15 + app/models/group.rb | 3 +- app/models/group_mixins/memberships.rb | 197 ------------ app/models/member_collection.rb | 55 ++++ app/models/membership_collection.rb | 7 + app/models/user.rb | 7 +- .../concerns/group_member_assignment_spec.rb | 79 +++++ .../group_memberships_spec.rb} | 95 +----- ...ity_range_for_indirect_memberships_spec.rb | 296 ------------------ spec/models/user_spec.rb | 10 +- 15 files changed, 310 insertions(+), 597 deletions(-) create mode 100644 app/models/concerns/group_member_assignment.rb create mode 100644 app/models/concerns/group_memberships.rb create mode 100644 app/models/member_collection.rb create mode 100644 spec/models/concerns/group_member_assignment_spec.rb rename spec/models/{group_mixins/memberships_spec.rb => concerns/group_memberships_spec.rb} (72%) delete mode 100644 spec/models/user_group_membership_mixins/validity_range_for_indirect_memberships_spec.rb diff --git a/app/controllers/group_members_controller.rb b/app/controllers/group_members_controller.rb index 0a0402639..b2a172161 100644 --- a/app/controllers/group_members_controller.rb +++ b/app/controllers/group_members_controller.rb @@ -31,9 +31,7 @@ def load_and_authorize_group def load_and_authorize_memberships @memberships = @group.memberships_for_member_list @memberships = @memberships.started_after(params[:valid_from].to_datetime) if params[:valid_from].present? - - allowed_members = @group.members.accessible_by(current_ability) - @memberships = @memberships.to_a.select { |membership| membership.user.in? allowed_members } + @memberships = @memberships.select { |membership| can? :read, membership.user } end def load_members_from_memberships diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index e47a7e6c6..604d932a9 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -51,8 +51,7 @@ def show Rack::MiniProfiler.step('groups#show controller: cancan') do # Make sure only members that are allowed to be seen are in this array! # - allowed_member_ids = @group.members.accessible_by(current_ability).pluck(:id) - @memberships = @memberships.to_a.select { |m| m.user.id.in? allowed_member_ids } + @memberships = @memberships.to_a.select { |m| can? :read, m.user } end Rack::MiniProfiler.step('groups#show controller: fetch members') do diff --git a/app/models/ability.rb b/app/models/ability.rb index b5aca3745..7432e3bd6 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -292,12 +292,12 @@ def rights_for_signed_in_users event.contact_people.include? user end can [:update, :create_attachment_for, :destroy], Page do |page| - page.ancestor_events.map(&:contact_people).flatten.include? user + page.ancestor_events.collect { |event| event.contact_people.to_a }.flatten.include? user end can [:update, :destroy], Attachment do |attachment| attachment.author == user and attachment.parent.kind_of?(Page) and - attachment.parent.ancestor_events.map(&:contact_people).flatten.include?(user) + attachment.parent.ancestor_events.collect { |event| event.contact_people.to_a }.flatten.include?(user) end # This allows all users to send posts to their own groups. diff --git a/app/models/concerns/group_member_assignment.rb b/app/models/concerns/group_member_assignment.rb new file mode 100644 index 000000000..cd4a4137a --- /dev/null +++ b/app/models/concerns/group_member_assignment.rb @@ -0,0 +1,67 @@ +concern :GroupMemberAssignment do + + # This assings the given user as a member to the group, i.e. this will + # create a UserGroupMembership. + # + def assign_user(user, options = {}) + if user and not user.in?(self.direct_members) + membership = Membership.create(user: user, group: self) + time_of_joining = options[:joined_at] || options[:at] || options[:time] || Time.zone.now + membership.update_attributes valid_from: time_of_joining + return membership + end + end + def assign(user, options = {}) + assign_user user, options + end + + # This method will remove a UserGroupMembership, i.e. terminate the membership + # of the given user in this group. + # + def unassign_user(user, options = {}) + if user and user.in?(self.members) + time_of_unassignment = options[:at] || options[:time] || Time.zone.now + Membership.where(user: user, group: self).first.invalidate(at: time_of_unassignment) + end + end + def unassign(user, options = {}) + unassign_user user, options + end + + # This returns a string of the titles of the direct members of this group. This is used + # for in-place editing, for example. + # + # The string would be something like this: + # + # "#{user1.title}, #{user2.title}, ..." + # + def direct_members_titles_string + direct_members.collect { |user| user.title }.join( ", " ) + end + + # This sets the memberships of a group according to the given string of user titles. + # + # For example, after calling + # + # direct_members_titles_string = "#{user1.title}, #{user2.title}", + # + # the users `user1` and `user2` are the only direct members of the group. + # The memberships are removed using the standard methods, which means that the memberships + # are only marked as deleted. See: acts_as_paranoid_dag gem. + # + def direct_members_titles_string=( titles_string ) + new_members_titles = titles_string.split( "," ) + new_members = new_members_titles.collect do |title| + u = User.find_by_title( title.strip ) + self.errors.add :direct_member_titles_string, 'user not found: #{title}' unless u + u + end + for member in self.direct_members + unassign_user member unless member.in? new_members if member + end + for new_member in new_members + assign_user new_member if new_member + end + end + +end \ No newline at end of file diff --git a/app/models/concerns/group_memberships.rb b/app/models/concerns/group_memberships.rb new file mode 100644 index 000000000..e9e876c4d --- /dev/null +++ b/app/models/concerns/group_memberships.rb @@ -0,0 +1,65 @@ +concern :GroupMemberships do + + def memberships + Membership.where(group: self).now + end + + def direct_memberships + memberships.direct + end + + def indirect_memberships + memberships.indirect + end + + def memberships_of(user) + memberships.where(user: user) + end + + def membership_of(user) + memberships_of(user).first + end + + def memberships_for_member_list + memberships.join_validity_ranges_of_indirect_memberships + end + def memberships_for_member_list_count + cached { memberships_for_member_list.count } + end + + # This method builds a new membership having this group (self) as group associated. + # + def build_membership + Membership.build(group: self) + end + + def latest_memberships + cached do + self.memberships.with_invalid.order_by(&:valid_from).last(10) + end + end + + def memberships_this_year + cached do + self.memberships.this_year + end + end + + def members(reload = false) + MemberCollection.new(memberships: memberships.join_validity_ranges_of_indirect_memberships, group: self) + end + + def member_ids(reload = false) + @member_ids = nil if reload + @member_ids ||= members.map(&:id) + end + + def direct_members(reload = false) + members.direct + end + + def indirect_members + members.indirect + end + +end \ No newline at end of file diff --git a/app/models/corporation.rb b/app/models/corporation.rb index 24329951f..5efbfa54e 100644 --- a/app/models/corporation.rb +++ b/app/models/corporation.rb @@ -76,5 +76,20 @@ def deceased_members def deceased_members_memberships child_groups.find_by_flag(:deceased_parent).try(:memberships) || [] end + + # This overrides the group memberships. + # + # For a corporation, the members of the 'former members' subgroup + # of the corporation are excluded, even though they still have + # memberships in the dag-link sense. + # + # They should not appear in member lists, list exports, mailing lists, + # et cetera. + # + alias_method :original_memberships, :memberships + def memberships + original_memberships.without(former_members, deceased_members) + end + end diff --git a/app/models/group.rb b/app/models/group.rb index 6192fcc94..ba5b52e0b 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -31,7 +31,8 @@ class Group < ActiveRecord::Base default_scope { includes(:flags) } - include GroupMixins::Memberships + include GroupMemberships + include GroupMemberAssignment include GroupMixins::Everyone include GroupMixins::Corporations include GroupMixins::Roles diff --git a/app/models/group_mixins/memberships.rb b/app/models/group_mixins/memberships.rb index 763a31826..170ce1184 100644 --- a/app/models/group_mixins/memberships.rb +++ b/app/models/group_mixins/memberships.rb @@ -8,202 +8,5 @@ module GroupMixins::Memberships included do - # User Group Memberships - # ========================================================================================== - - # This associates all UserGroupMembership objects of the group, including indirect - # memberships. - # - has_many( :memberships, - -> { where ancestor_type: 'Group', descendant_type: 'User' }, - class_name: 'UserGroupMembership', - foreign_key: :ancestor_id ) - - # This associates all memberships of the group that are direct, i.e. direct - # parent_group-child_user memberships. - # - has_many( :direct_memberships, - -> { where ancestor_type: 'Group', descendant_type: 'User', direct: true }, - class_name: 'UserGroupMembership', - foreign_key: :ancestor_id ) - - # This associates all memberships of the group that are indirect, i.e. - # ancestor_group-descendant_user memberships, where groups are between the - # ancestor_group and the descendant_user. - # - has_many( :indirect_memberships, - -> { where ancestor_type: 'Group', descendant_type: 'User', direct: false }, - class_name: 'UserGroupMembership', - foreign_key: :ancestor_id ) - - - # This method builds a new membership having this group (self) as group associated. - # - def build_membership - Membership.build(group: self) - end - - # This returns the UserGroupMembership object that represents the membership of the - # given user in this group. - # - # options: - # - also_in_the_past - # - def membership_of(user, options = {}) - if options[:also_in_the_past] - base = UserGroupMembership.with_invalid - else - base = UserGroupMembership - end - base.find_by_user_and_group(user, self) - end - - # This returns a string of the titles of the direct members of this group. This is used - # for in-place editing, for example. - # - # The string would be something like this: - # - # "#{user1.title}, #{user2.title}, ..." - # - def direct_members_titles_string - direct_members.collect { |user| user.title }.join( ", " ) - end - - # This sets the memberships of a group according to the given string of user titles. - # - # For example, after calling - # - # direct_members_titles_string = "#{user1.title}, #{user2.title}", - # - # the users `user1` and `user2` are the only direct members of the group. - # The memberships are removed using the standard methods, which means that the memberships - # are only marked as deleted. See: acts_as_paranoid_dag gem. - # - def direct_members_titles_string=( titles_string ) - new_members_titles = titles_string.split( "," ) - new_members = new_members_titles.collect do |title| - u = User.find_by_title( title.strip ) - self.errors.add :direct_member_titles_string, 'user not found: #{title}' unless u - u - end - for member in self.direct_members - unassign_user member unless member.in? new_members if member - end - for new_member in new_members - assign_user new_member if new_member - end - end - - def memberships_including_members - memberships.includes(:descendant).order(valid_from: :desc) - end - - # This returns the memberships that appear in the member list - # of the group. - # - # For a regular group, these are just the usual memberships. - # For a corporation, the members of the 'former members' subgroup - # of the corporation are excluded, even though they still have - # memberships. - # - def memberships_for_member_list - Membership.where(group: self).now.join_validity_ranges_of_indirect_memberships - - # - # TODO: Use and override `memberships` instead. - # - - # cached do - # if corporation? - # ( - # memberships_including_members - - # becomes(Corporation).former_members_memberships - - # becomes(Corporation).deceased_members_memberships - # ) - # else - # memberships_including_members - # end - # end - end - def memberships_for_member_list_count - cached { memberships_for_member_list.count } - end - - def latest_memberships - cached do - self.memberships.with_invalid.reorder('valid_from DESC').limit(10).includes(:descendant) - end - end - - def memberships_this_year - cached do - self.memberships.this_year - end - end - - # User Assignment - # ========================================================================================== - - # This assings the given user as a member to the group, i.e. this will - # create a UserGroupMembership. - # - def assign_user( user, options = {} ) - if user and not user.in?(self.direct_members) - membership = Membership.create(user: user, group: self) - time_of_joining = options[:joined_at] || options[:at] || options[:time] || Time.zone.now - membership.update_attributes valid_from: time_of_joining - return membership - end - end - - # This method will remove a UserGroupMembership, i.e. terminate the membership - # of the given user in this group. - # - def unassign_user( user, options = {} ) - if user and user.in?(self.members) - time_of_unassignment = options[:at] || options[:time] || Time.zone.now - UserGroupMembership.find_by(user: user, group: self).invalidate(at: time_of_unassignment) - end - end - - - def calculate_validity_range_of_indirect_memberships - self.indirect_memberships.where(valid_from: nil).each do |membership| - membership.recalculate_validity_range_from_direct_memberships - membership.save - end - end - - - # Members - # ========================================================================================== - - # This associates the group members (users), direct ones as well as indirect ones. - # - # Attention! The conditions on the `memberships` association are ignored by Rails 3 - # when generating the SQL query. This is why the conditions have to be repeated here. - # - has_many(:members, - -> { where('dag_links.ancestor_type' => 'Group').uniq }, - through: :memberships, - source: :descendant, source_type: 'User' - ) - - # This associates only the direct group members (users). - # - has_many(:direct_members, - -> { where('dag_links.ancestor_type' => 'Group', 'dag_links.direct' => true).uniq }, - through: :direct_memberships, - source: :descendant, source_type: 'User' - ) - - # This associates only the indirect group members (users). - # - has_many(:indirect_members, - -> { where('dag_links.ancestor_type' => 'Group', 'dag_links.direct' => false).uniq }, - through: :indirect_memberships, - source: :descendant, source_type: 'User' - ) - end end diff --git a/app/models/member_collection.rb b/app/models/member_collection.rb new file mode 100644 index 000000000..b4d3b9d24 --- /dev/null +++ b/app/models/member_collection.rb @@ -0,0 +1,55 @@ +class MemberCollection + + def initialize(attrs = {}) + @group = attrs[:group] + @memberships = attrs[:memberships] || raise('no memberships (MembershipCollection) given.') + @memberships.kind_of?(MembershipCollection) || raise('memberships needs to be a MembershipCollection.') + end + + def to_a + @memberships.to_a.collect { |membership| membership.user } + end + + def find_all_by_flag(flag) + flagged(flag) + end + + def now + @memberships = @memberships.now + return self + end + + def with_past + @memberships = @memberships.with_past + return self + end + + def past + @memberships = @memberships.past + return self + end + def former + past + end + + def direct + @memberships = @memberships.direct + return self + end + + def indirect + @memberships = @memberships.indirect + return self + end + + delegate :count, :first, :last, to: :to_a + delegate :each, :map, :collect, :select, :include?, :+, :-, :&, to: :to_a + + # Add a user as another member. + # + def <<(user) + @group || raise('No :group given during MemberCollection initialization.') + @group << user + end + +end \ No newline at end of file diff --git a/app/models/membership_collection.rb b/app/models/membership_collection.rb index ccbb94665..e9663587e 100644 --- a/app/models/membership_collection.rb +++ b/app/models/membership_collection.rb @@ -25,6 +25,12 @@ def uniq return self end + def without(*without_members) + # `flatten` for `without(blondes, brunettes)`, which are two arrays. + @without_members = without_members.flatten + return self + end + # If a user has two memberships in a group, differing in the validity range, # this filter selects the first, i.e. earliest, membership for each group. # @@ -69,6 +75,7 @@ def to_a memberships.detect { |m| m.valid_from.to_i == min_valid_from_to_i } end end + memberships.select! { |m| not m.user.in? @without_members } if @without_members return memberships end diff --git a/app/models/user.rb b/app/models/user.rb index 6f95aaf34..3557d1465 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -557,12 +557,9 @@ def join(event_or_group) end def leave(event_or_group) if event_or_group.kind_of? Group - # TODO: Change to `unassign` when he can have multiple dag links between two nodes. - # event_or_group.members.destroy(self) - raise 'We need multiple dag links between two nodes!' + event_or_group.unassign self elsif event_or_group.kind_of? Event - # TODO: Change to `unassign` when he can have multiple dag links between two nodes. - event_or_group.attendees_group.members.destroy(self) + event_or_group.attendees_group.unassign self end end diff --git a/spec/models/concerns/group_member_assignment_spec.rb b/spec/models/concerns/group_member_assignment_spec.rb new file mode 100644 index 000000000..289c0b1f5 --- /dev/null +++ b/spec/models/concerns/group_member_assignment_spec.rb @@ -0,0 +1,79 @@ +require 'spec_helper' + +describe GroupMemberAssignment do + + # @indirect_group + # |------------ @group + # | |------ @user1 + # | |------ @user2 + # | + # |------------ @group2 + # + before do + @group = create(:group) + @user1 = create(:user); @group.assign_user(@user1) + @user2 = create(:user); @group.assign_user(@user2) + @user = @user1 + @membership1 = UserGroupMembership.find_by(user: @user1, group: @group) + @membership2 = UserGroupMembership.find_by(user: @user2, group: @group) + @indirect_group = @group.parent_groups.create + @indirect_membership1 = UserGroupMembership.find_by(user: @user1, group: @indirect_group) + @indirect_membership2 = UserGroupMembership.find_by(user: @user2, group: @indirect_group) + @group2 = @indirect_group.child_groups.create + end + + + # User Assignment + # ========================================================================================== + + describe "#assign_user" do + before { @membership1.destroy } + it "should assign the user to the group" do + @group.members.should_not include @user + @group.assign_user @user + @group.reload + @group.members.should include @user + end + describe "for users that are already members" do + before { @group.direct_members << @user } + it "should just keep them as members" do + @group.members.should include @user + @group.assign_user @user + @group.reload + @group.members.should include @user + end + end + end + + describe "#unassign_user" do + before { @membership1.destroy } + describe "if the user is a member" do + before { @group.direct_members << @user } + it "should remove the membership" do + @group.members.should include @user + @group.unassign_user @user + time_travel 2.seconds + @group.reload.members.should_not include @user + end + end + describe "if the user is not a member" do + it "should not raise an error" do + @group.members.should_not include @user + expect { @group.unassign_user @user }.to_not raise_error + end + end + end + + describe "#direct_member_titles_string" do + subject { @group.direct_members_titles_string } + it { should == "#{@user1.title}, #{@user2.title}" } + end + describe "#direct_member_titles_string=" do + before { @group.direct_members_titles_string = "#{@user1.title}"; time_travel 2.seconds } + it "should set the memberships according to the titles" do + @group.reload.memberships.should include( @membership1 ) + @group.reload.memberships.should_not include( @membership2 ) + end + end + +end diff --git a/spec/models/group_mixins/memberships_spec.rb b/spec/models/concerns/group_memberships_spec.rb similarity index 72% rename from spec/models/group_mixins/memberships_spec.rb rename to spec/models/concerns/group_memberships_spec.rb index 7c627b86b..a44a19a2d 100644 --- a/spec/models/group_mixins/memberships_spec.rb +++ b/spec/models/concerns/group_memberships_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe GroupMixins::Memberships do +describe GroupMemberships do # @indirect_group # |------------ @group @@ -14,11 +14,11 @@ @user1 = create(:user); @group.assign_user(@user1) @user2 = create(:user); @group.assign_user(@user2) @user = @user1 - @membership1 = UserGroupMembership.find_by(user: @user1, group: @group) - @membership2 = UserGroupMembership.find_by(user: @user2, group: @group) + @membership1 = Membership.where(user: @user1, group: @group).first + @membership2 = Membership.where(user: @user2, group: @group).first @indirect_group = @group.parent_groups.create - @indirect_membership1 = UserGroupMembership.find_by(user: @user1, group: @indirect_group) - @indirect_membership2 = UserGroupMembership.find_by(user: @user2, group: @indirect_group) + @indirect_membership1 = Membership.where(user: @user1, group: @indirect_group).first + @indirect_membership2 = Membership.where(user: @user2, group: @indirect_group).first @group2 = @indirect_group.child_groups.create end @@ -41,13 +41,13 @@ before { @membership1.invalidate at: 10.minutes.ago } subject { @group.memberships } it { should include @membership2 } - it { should == [ @membership2 ] } + its(:count) { should == 1 } it "should not list the invalidated memberships, i.e. respect the default scope" do subject.should_not include @membership1 end end end - + describe "#direct_memberships" do describe "for a group having direct members" do subject { @group.direct_memberships } @@ -84,51 +84,9 @@ describe "#membership_of( user )" do subject { @group.membership_of(@user1) } - it { should be_kind_of UserGroupMembership } + it { should be_kind_of Membership } it { should == @membership1 } end - - - # User Assignment - # ========================================================================================== - - describe "#assign_user" do - before { @membership1.destroy } - it "should assign the user to the group" do - @group.members.should_not include @user - @group.assign_user @user - @group.reload - @group.members.should include @user - end - describe "for users that are already members" do - before { @group.direct_members << @user } - it "should just keep them as members" do - @group.members.should include @user - @group.assign_user @user - @group.reload - @group.members.should include @user - end - end - end - - describe "#unassign_user" do - before { @membership1.destroy } - describe "if the user is a member" do - before { @group.direct_members << @user } - it "should remove the membership" do - @group.members.should include @user - @group.unassign_user @user - time_travel 2.seconds - @group.reload.members.should_not include @user - end - end - describe "if the user is not a member" do - it "should not raise an error" do - @group.members.should_not include @user - expect { @group.unassign_user @user }.to_not raise_error - end - end - end # Members @@ -181,26 +139,6 @@ @user.should be_in @group2.direct_members end end - describe "#members.destroy(user)" do - describe "for the membership being direct" do - subject { @group.members.destroy(@user1) } - it "should remove the user from the members list" do - @user1.should be_in @group.members - subject - @user1.should_not be_in @group.members - end - it "should remove the membership permanently" do - subject - UserGroupMembership.with_invalid.find_by_user_and_group(@user1, @group).should == nil - end - end - describe "for the membership being indirect" do - subject { @indirect_group.members.destroy(@user1) } - it "should raise an error" do - expect { subject }.to raise_error - end - end - end describe "#direct_members" do describe "for a group having direct members" do @@ -284,18 +222,5 @@ it { should include @user_unique2 } end end - - describe "#direct_member_titles_string" do - subject { @group.direct_members_titles_string } - it { should == "#{@user1.title}, #{@user2.title}" } - end - describe "#direct_member_titles_string=" do - before { @group.direct_members_titles_string = "#{@user1.title}"; time_travel 2.seconds } - it "should set the memberships according to the titles" do - @group.reload.memberships.should include( @membership1 ) - @group.reload.memberships.should_not include( @membership2 ) - end - end - - -end + +end \ No newline at end of file diff --git a/spec/models/user_group_membership_mixins/validity_range_for_indirect_memberships_spec.rb b/spec/models/user_group_membership_mixins/validity_range_for_indirect_memberships_spec.rb deleted file mode 100644 index f0f560f4f..000000000 --- a/spec/models/user_group_membership_mixins/validity_range_for_indirect_memberships_spec.rb +++ /dev/null @@ -1,296 +0,0 @@ -require 'spec_helper' - -describe UserGroupMembershipMixins::ValidityRangeForIndirectMemberships do - - # Memberships: - # - # *-----------------(c)--------------------* - # | - # |--------------------| - # | | - # *-------(a)--------* | - # *---------(b)---------* - # - # _________________________________________________________ - # t1 t2 t3 time --> - # - # Structure: - # - # indirect_group ........................... (c) - # |---------- direct_group ........... (a), (b) - # |--------- user - # - before do - @user = create(:user) - @indirect_group = create(:group) - @direct_group_a = @indirect_group.child_groups.create - @direct_group_b = @indirect_group.child_groups.create - @direct_group_a.assign_user @user - @indirect_membership = UserGroupMembership.find_by_user_and_group(@user, @indirect_group) - @direct_membership_a = UserGroupMembership.find_by_user_and_group(@user, @direct_group_a) - @direct_membership_b = @direct_membership_a.move_to_group(@direct_group_b) - @t1 = 2.hours.ago - @t2 = 1.hour.ago - @t3 = nil - @direct_membership_a.update_attribute(:valid_from, @t1) - @direct_membership_a.update_attribute(:valid_to, @t2) - @direct_membership_b.update_attribute(:valid_from, @t2) - @direct_membership_b.update_attribute(:valid_to, @t3) - @indirect_membership.delete_cache - - @direct_membership_a.reload - @direct_membership_b.reload - @indirect_membership.reload - end - - specify "preliminaries" do - @direct_membership_a.valid_from.to_i.should < @direct_membership_b.valid_from.to_i - - #@direct_membership_a.valid_to.should < @direct_membership_b.valid_to - end - - - # Validity Range Attributes - # ==================================================================================================== - - describe "#valid_from" do - subject { @indirect_membership.valid_from } - it "should be the valid_from attribute of the earliest direct membership" do - subject.to_i.should == @direct_membership_a.valid_from.to_i - end - end - describe "#valid_from=" do - before { @time = 30.minutes.ago } - subject { @indirect_membership.valid_from = @time } - it "should set the valid_from attribute of the earliset direct membership" do - subject - @indirect_membership.save - @direct_membership_a.reload.valid_from.to_i.should == @time.to_i - end - end - - describe "#valid_to" do - subject { @indirect_membership.valid_to } - it "should be the valid_to attribute of the latest direct membership" do - subject.to_i.should == @direct_membership_b.valid_to.to_i - end - end - describe "#valid_to=" do - before { @time = 30.minutes.ago } - subject { @indirect_membership.valid_to = @time } - it "should set the valid_to addtirbute of the last direct membership" do - subject - @indirect_membership.save - @direct_membership_b.reload.valid_to.to_i.should == @time.to_i - end - end - - describe "#earliest_direct_membership" do - subject { @indirect_membership.earliest_direct_membership } - it { should == @direct_membership_a } - end - - describe "#latest_direct_membership" do - subject { @indirect_membership.latest_direct_membership } - it { should == @direct_membership_b } - end - - describe "#recalculate_validity_range_from_direct_memberships" do - before do - @t1 = 10.hours.ago; @t2 = 8.hours.ago; @t3 = 37.minutes.ago - @direct_membership_a.update_attribute(:valid_from, @t1) - @direct_membership_a.update_attribute(:valid_to, @t2) - @direct_membership_b.update_attribute(:valid_from, @t2) - @direct_membership_b.update_attribute(:valid_to, @t3) - end - subject { @indirect_membership.reload.recalculate_validity_range_from_direct_memberships; @indirect_membership.reload } - it "should make the indirect validity range match the direct memberships' combined range" do - subject - @indirect_membership.valid_from.to_i.should == @t1.to_i - @indirect_membership.valid_to.to_i.should == @t3.to_i - end - it "should write the indirect ranges to the database" do - subject - @indirect_membership.read_attribute(:valid_from).to_i.should == @t1.to_i - @indirect_membership.read_attribute(:valid_to).to_i.should == @t3.to_i - end - it "should persist in the graph" do - subject - DagLink.find(@indirect_membership.id).valid_from.to_i.should == @t1.to_i - DagLink.find(@indirect_membership.id).valid_to.to_i.should == @t3.to_i - end - describe "for the earliest valid_from being nil" do - before { @direct_membership_a.update_attribute(:valid_from, nil) } - specify "prelims" do - @reloaded_direct_membership_a = UserGroupMembership.with_invalid.find(@direct_membership_a.id) - @reloaded_direct_membership_a.read_attribute(:valid_from).should == nil - end - it "should set the indirect valid_from to nil" do - subject - @indirect_membership.read_attribute(:valid_from).should == nil - end - end - describe "(bug fix: reproducing status group membership scenario)" do - # @corporation - # |------- @intermediate_group - # |------------ @status_group - # | |--------- (@user) - # | - # |------------ @second_status_group - # |--------- @user - before do - @corporation = create( :corporation, name: "Corporation" ) - @intermediate_group = create( :group, name: "Not a Status Group" ) - @status_group = create( :group, name: "Status Group" ) - @intermediate_group.parent_groups << @corporation - @status_group.parent_groups << @intermediate_group - @user = create( :user ) - @status_group.assign_user @user - @membership = UserGroupMembership.find_by_user_and_group( @user, @status_group ) - @intermediate_group_membership = UserGroupMembership - .find_by_user_and_group( @user, @intermediate_group ) - @second_status_group = @intermediate_group.child_groups.create(name: "Second Status Group") - @membership.update_attribute(:valid_from, 1.year.ago) - @corpo_membership = UserGroupMembership.find_by_user_and_group(@user, @corporation) - end - specify "prelims" do - @user.should be_kind_of User - @corporation.reload.should be_kind_of Corporation - @corporation.descendants.should include @intermediate_group, @status_group, @second_status_group, @user - @intermediate_group.reload.descendants.should include @status_group, @second_status_group, @user - @status_group.reload.descendants.should include @user - end - describe "promoting the membership" do - subject { @second_membership = @membership.move_to(@second_status_group, at: 20.day.ago) } - # @membership valid_from: 1.year.ago, valid_to: 20.days.ago - # @second_membership valid_from: 20.days.ago, valid_to: nil - # @corpo_membership valid_from: 1.year.ago, valid_to: nil - before { subject } - - it "should update the valid_from and valid_to of the indirect membership" do - @corpo_membership.read_attribute(:valid_from).to_date.should == 1.year.ago.to_date - @corpo_membership.read_attribute(:valid_to).should == nil - end - it "should update the validity range persistent" do - @reloaded_corpo_membership = UserGroupMembership.find(@corpo_membership.id) - @reloaded_corpo_membership.should == @corpo_membership - @reloaded_corpo_membership.valid_from.to_date.should == 1.year.ago.to_date - @reloaded_corpo_membership.valid_to.should == nil - end - it "should update the valid_from and valid_to of the corresponding graph link" do - @link = DagLink.find(@corpo_membership.id) - @link.valid_from.to_date.should == 1.year.ago.to_date - @link.valid_to.should == nil - end - it "should update the graph structure" do - @second_status_group.reload.descendants.should include @user - @corporation.reload.descendants.should include @user - end - it "should update the corporation members correctly" do - @corporation.members.should include @user - @user.should be_member_of @corporation - end - specify "the corporation members should match the memberships in number" do - @corporation.memberships.count.should > 0 - @corporation.memberships.count.should == @corporation.members.count - end - specify "the indirect membership should be included in the memberships associated with the corporation" do - @corporation.memberships.should include @corpo_membership - end - it "should make no difference if the validity range is forcefully updated" do - @corpo_membership.read_attribute(:valid_from).to_date.should == 1.year.ago.to_date - @corpo_membership.read_attribute(:valid_to).should == nil - - @membership.update_attribute(:valid_from, 1.year.ago) - @membership.update_attribute(:valid_to, 20.days.ago) - @second_membership.update_attribute(:valid_from, 20.days.ago) - @second_membership.update_attribute(:valid_to, nil) - - @corpo_membership = UserGroupMembership.find(@corpo_membership.id) - @corpo_membership.read_attribute(:valid_from).to_date.should == 1.year.ago.to_date - @corpo_membership.read_attribute(:valid_to).should == nil - end - end - end - end - - - # Invalidation - # ==================================================================================================== - - describe "#make_invalid" do - subject { @indirect_membership.make_invalid } - it "should raise an error" do - expect { subject }.to raise_error - end - end - describe "#invalidate" do - subject { @indirect_membership.invalidate } - it "should raise an error" do - expect { subject }.to raise_error - end - end - - - # Validity Check - # ==================================================================================================== - - describe "#valid_at?(time)" do - subject { @indirect_membership.valid_at? @time_to_check } - specify "preliminaries" do - @indirect_membership.earliest_direct_membership.valid_from.to_i.should == @t1.to_i - @indirect_membership.earliest_direct_membership.valid_to.to_i.should == @t2.to_i - @indirect_membership.latest_direct_membership.valid_from.to_i.should == @t2.to_i - @indirect_membership.latest_direct_membership.valid_to.should == @t3 - end - it "should return false before the early direct membership" do - @time_to_check = 3.hours.ago - subject.should == false - end - it "should return true for the duration of the early direct membership" do - @time_to_check = 1.5.hours.ago - subject.should == true - end - it "should return true for the duration of the late direct membership" do - @time_to_check = 0.5.hours.ago - subject.should == true - end - end - - - # Temporal scopes - # ==================================================================================================== - - describe "#at_time" do - subject { UserGroupMembership.find_all_by_user(@user).at_time(30.minutes.ago) } - specify "preliminaries" do - @direct_membership_a.valid_from.to_i.should == @t1.to_i - @direct_membership_a.valid_to.to_i.should == @t2.to_i - @direct_membership_b.valid_from.to_i.should == @t2.to_i - @direct_membership_b.valid_to.should == @t3 - @indirect_membership.valid_from.to_i.should == @t1.to_i - @indirect_membership.valid_to.should == @t3 - # @indirect_membership.read_attribute(:valid_from).to_i.should == @t1.to_i - # @indirect_membership.read_attribute(:valid_to).should == @t3 - end - it "should find the direct membership" do - subject.should include @direct_membership_b - end - it "should find the indirect membership as well" do - subject.should include @indirect_membership - end - end - - describe "#only_valid" do - subject { UserGroupMembership.only_valid.find_all_by_user(@user) } - it "should find the valid indirect memberships" do - subject.should include @indirect_membership - end - it "should not find the invalid indirect memberships" do - @direct_membership_b.invalidate at: 20.minutes.ago - subject.should_not include @indirect_membership - end - end - - -end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 27ab3cb4a..c6d7f577a 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -1155,18 +1155,16 @@ time_travel 2.seconds end describe "(leaving an event)" do - # TODO: We need multiple dag links between two nodes! before { @event_or_group = @event; subject } specify { @event.attendees.should_not include @user} specify { @event.attendees_group.members.should_not include @user } - specify { @event.attendees_group.child_users.should_not include @user } + specify { @event.attendees_group.child_users.should include @user } end describe "(leaving a group)" do before { @event_or_group = @group; subject } - # TODO: We need multiple dag links between two nodes! - # specify { @group.members.should_not include @user } - # specify { @group.members.former.should include @user } - # specify { @group.child_users.should include @user } + specify { @group.members.should_not include @user } + specify { @group.members.former.should include @user } + specify { @group.child_users.should include @user } end end From d30e66189d7267494cb2f7dc6bcbc394f7d83e79 Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Sun, 4 Oct 2015 13:04:30 +0200 Subject: [PATCH 28/42] Membership: introduce some model caching --- app/models/concerns/group_memberships.rb | 2 +- app/models/group_mixins/memberships.rb | 12 ------- app/models/membership_collection.rb | 45 +++++++++++++++--------- 3 files changed, 29 insertions(+), 30 deletions(-) delete mode 100644 app/models/group_mixins/memberships.rb diff --git a/app/models/concerns/group_memberships.rb b/app/models/concerns/group_memberships.rb index e9e876c4d..cca63758b 100644 --- a/app/models/concerns/group_memberships.rb +++ b/app/models/concerns/group_memberships.rb @@ -1,6 +1,6 @@ concern :GroupMemberships do - def memberships + def memberships(reload = false) Membership.where(group: self).now end diff --git a/app/models/group_mixins/memberships.rb b/app/models/group_mixins/memberships.rb deleted file mode 100644 index 170ce1184..000000000 --- a/app/models/group_mixins/memberships.rb +++ /dev/null @@ -1,12 +0,0 @@ -# -# This module contains the methods of the Group model regarding the associated -# user group memberships and users, i.e. members. -# -module GroupMixins::Memberships - - extend ActiveSupport::Concern - - included do - - end -end diff --git a/app/models/membership_collection.rb b/app/models/membership_collection.rb index e9663587e..11a27f4aa 100644 --- a/app/models/membership_collection.rb +++ b/app/models/membership_collection.rb @@ -57,26 +57,28 @@ def join_validity_ranges_of_indirect_memberships end def to_a - memberships = [] - memberships += find_all_direct_memberships unless @indirect - unless @direct - memberships += if @user and not @group - find_all_indirect_memberships_by_user - elsif @group and not @user - find_all_indirect_memberships_by_group - elsif @user and @group - find_all_indirect_memberships_by_user_and_group + Rails.cache.fetch [cache_key, 'to_a'] do + memberships = [] + memberships += find_all_direct_memberships unless @indirect + unless @direct + memberships += if @user and not @group + find_all_indirect_memberships_by_user + elsif @group and not @user + find_all_indirect_memberships_by_group + elsif @user and @group + find_all_indirect_memberships_by_user_and_group + end end - end - memberships = memberships.uniq { |m| [m.group.id, m.user.id, m.valid_from, m.valid_to] } if @uniq - if @first_per_group - memberships = memberships.group_by { |m| [m.group, m.user] }.collect do |group_and_user, memberships| - min_valid_from_to_i = memberships.collect { |m| m.valid_from.to_i }.min - memberships.detect { |m| m.valid_from.to_i == min_valid_from_to_i } + memberships = memberships.uniq { |m| [m.group.id, m.user.id, m.valid_from, m.valid_to] } if @uniq + if @first_per_group + memberships = memberships.group_by { |m| [m.group, m.user] }.collect do |group_and_user, memberships| + min_valid_from_to_i = memberships.collect { |m| m.valid_from.to_i }.min + memberships.detect { |m| m.valid_from.to_i == min_valid_from_to_i } + end end + memberships.select! { |m| not m.user.in? @without_members } if @without_members + memberships end - memberships.select! { |m| not m.user.in? @without_members } if @without_members - return memberships end def groups @@ -175,4 +177,13 @@ def max_valid_to_of(dag_links) max_valid_to = valid_to_nil ? nil : dag_links.maximum(:valid_to) end + def cache_key + [primary_cache_scope, "membership_collection", + @direct, @indirect, @uniq, @first_per_group, @join_validity_ranges_of_indirect_memberships, @without_members, + @valid, @invalid, @with_invalid, @now, @past, @with_past, @now_and_in_the_past, @at_time, @this_year, @started_after] + end + def primary_cache_scope + @user || @group || raise('Neither user nor group given. Unable to find cache scope.') + end + end \ No newline at end of file From 4f2028b0716124b6664ba4ea411e9aa5d1d64ab3 Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Sun, 1 Nov 2015 14:58:23 +0100 Subject: [PATCH 29/42] connected groups: working on Group#members --- Gemfile.lock | 11 +++++++---- app/models/concerns/group_mailing_lists.rb | 8 ++++---- app/models/concerns/group_memberships.rb | 2 +- app/models/concerns/user_roles.rb | 8 ++++++++ app/models/group_collection.rb | 2 +- app/models/membership.rb | 3 +++ app/models/membership_collection.rb | 2 +- app/models/structureable_mixins/roles.rb | 3 +++ spec/models/ability_to_use_mailing_lists_spec.rb | 3 ++- 9 files changed, 30 insertions(+), 12 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 6755bfb09..410230aa3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -47,6 +47,7 @@ PATH edit_mode (>= 1.0.2) eventmachine (>= 1.0.7) execjs (>= 2.5.2) + faker fnordmetric font-awesome-rails (~> 4.3.0) foreman @@ -241,6 +242,8 @@ GEM factory_girl_rails (4.5.0) factory_girl (~> 4.5.0) railties (>= 3.0.0) + faker (1.5.0) + i18n (~> 0.5) fastercsv (1.5.5) ffi (1.9.8) fnordmetric (1.2.9) @@ -359,7 +362,7 @@ GEM orm_adapter (0.5.0) passgen (1.0.2) pdf-core (0.5.1) - phony (2.15.4) + phony (2.15.6) poltergeist (1.6.0) capybara (~> 2.1) cliver (~> 0.3.1) @@ -405,8 +408,8 @@ GEM rails-deprecated_sanitizer (>= 1.0.1) rails-html-sanitizer (1.0.2) loofah (~> 2.0) - rails-i18n (4.0.5) - i18n (~> 0.6) + rails-i18n (4.0.6) + i18n (~> 0.7) railties (~> 4.0) rails-settings-cached (0.4.1) rails (>= 4.0.0) @@ -440,7 +443,7 @@ GEM redis-actionpack (~> 4) redis-activesupport (~> 4) redis-store (~> 1.1.0) - redis-store (1.1.6) + redis-store (1.1.7) redis (>= 2.2) ref (1.0.5) refile (0.5.5) diff --git a/app/models/concerns/group_mailing_lists.rb b/app/models/concerns/group_mailing_lists.rb index 21b11ebb6..d4d1fcb37 100644 --- a/app/models/concerns/group_mailing_lists.rb +++ b/app/models/concerns/group_mailing_lists.rb @@ -42,12 +42,12 @@ def user_matches_mailing_list_sender_filter?(user) # Everyone can contact officers. true elsif self.corporation.present? - # If the group has an associated corporation, all members + # If the group has an associated corporation, all members and officers # of the corporation can post. - user && user.member_of?(self.corporation) + user && (user.member_of?(self.corporation) || user.officer_or_subgroup_officer_of?(self.corporation)) else - # If this is a regular group, all group members can post. - user && user.member_of?(self) + # If this is a regular group, all group members and officers can post. + user && (user.member_of?(self) || user.officer_of?(self)) end else false diff --git a/app/models/concerns/group_memberships.rb b/app/models/concerns/group_memberships.rb index cca63758b..3054104ac 100644 --- a/app/models/concerns/group_memberships.rb +++ b/app/models/concerns/group_memberships.rb @@ -55,7 +55,7 @@ def member_ids(reload = false) end def direct_members(reload = false) - members.direct + MemberCollection.new(memberships: memberships.direct, group: self) end def indirect_members diff --git a/app/models/concerns/user_roles.rb b/app/models/concerns/user_roles.rb index 7654ace07..34d41382c 100644 --- a/app/models/concerns/user_roles.rb +++ b/app/models/concerns/user_roles.rb @@ -189,6 +189,14 @@ def global_admin=(new_setting) def officer_of_anything? self.groups.detect { |g| g.type == 'OfficerGroup' } || false end + + def officer_of?(obj) + obj.officer_groups.collect { |g| g.members.to_a }.flatten.include? self + end + + def officer_or_subgroup_officer_of?(obj) + obj.officers_groups_of_self_and_descendant_groups.collect { |g| g.members.to_a }.flatten.include? self + end # Methods transferred from former Role class diff --git a/app/models/group_collection.rb b/app/models/group_collection.rb index 0338eea4f..933ed8fa7 100644 --- a/app/models/group_collection.rb +++ b/app/models/group_collection.rb @@ -36,6 +36,6 @@ def past end delegate :count, :first, :last, to: :to_a - delegate :map, :collect, :select, :include?, :+, :-, :&, to: :to_a + delegate :map, :collect, :select, :detect, :include?, :+, :-, :&, to: :to_a end \ No newline at end of file diff --git a/app/models/membership.rb b/app/models/membership.rb index a32f4b447..14eabac10 100644 --- a/app/models/membership.rb +++ b/app/models/membership.rb @@ -119,6 +119,9 @@ def self.create(params) valid_from: params[:valid_from] || Time.zone.now, valid_to: params[:valid_to]) + user.delete_cache + group.delete_cache + Membership.new(user: user, group: group, valid_from: new_dag_link.valid_from, valid_to: new_dag_link.valid_to) end diff --git a/app/models/membership_collection.rb b/app/models/membership_collection.rb index 11a27f4aa..f59c2bc4d 100644 --- a/app/models/membership_collection.rb +++ b/app/models/membership_collection.rb @@ -86,7 +86,7 @@ def groups end delegate :count, :first, :last, to: :to_a - delegate :map, :collect, :select, :each, to: :to_a + delegate :map, :collect, :select, :detect, :each, to: :to_a def include?(*other_memberships) to_a.collect { |m| [m.group.id, m.user.id, m.valid_from, m.valid_to] } diff --git a/app/models/structureable_mixins/roles.rb b/app/models/structureable_mixins/roles.rb index f8b386001..8d26845d1 100644 --- a/app/models/structureable_mixins/roles.rb +++ b/app/models/structureable_mixins/roles.rb @@ -120,6 +120,9 @@ def find_officers_groups def officers_groups self.officers_parent.descendant_officer_groups end + def officer_groups + self.officers_groups + end def direct_officers self.find_officers_parent_group.try(:descendant_users) || [] diff --git a/spec/models/ability_to_use_mailing_lists_spec.rb b/spec/models/ability_to_use_mailing_lists_spec.rb index 47533638f..29a31c741 100644 --- a/spec/models/ability_to_use_mailing_lists_spec.rb +++ b/spec/models/ability_to_use_mailing_lists_spec.rb @@ -296,12 +296,13 @@ end end - describe "for officers of the @group" do + describe "for officers (and members) of the @group" do before do @corporation = create :corporation_with_status_groups @corporation << @group @officer_group = @group.create_officer_group name: 'President' @officer_group.assign_user user + @group.assign_user user end describe "sender filter" do From 704a20bfc332c6d22668a5911a9644b5eabcdd33 Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Tue, 3 Nov 2015 14:59:54 +0100 Subject: [PATCH 30/42] =?UTF-8?q?connected=20groups:=20fixing=20caching=20?= =?UTF-8?q?issue=20in=20Membership.where(user:=20=E2=80=A6,=20group:=20?= =?UTF-8?q?=E2=80=A6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Despite the primary cache scope, we need the user and the group in the cache key, since Membership.where(…) can specify both, user and group. --- app/models/membership_collection.rb | 3 +- spec/models/membership_collection_spec.rb | 38 +++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/app/models/membership_collection.rb b/app/models/membership_collection.rb index f59c2bc4d..bbd33d03a 100644 --- a/app/models/membership_collection.rb +++ b/app/models/membership_collection.rb @@ -178,7 +178,8 @@ def max_valid_to_of(dag_links) end def cache_key - [primary_cache_scope, "membership_collection", + [primary_cache_scope, "membership_collection", + @user, @group, # we need both for Membership.where(user: ..., group: ...) despite the primary scope. @direct, @indirect, @uniq, @first_per_group, @join_validity_ranges_of_indirect_memberships, @without_members, @valid, @invalid, @with_invalid, @now, @past, @with_past, @now_and_in_the_past, @at_time, @this_year, @started_after] end diff --git a/spec/models/membership_collection_spec.rb b/spec/models/membership_collection_spec.rb index a9f391f3f..458df7204 100644 --- a/spec/models/membership_collection_spec.rb +++ b/spec/models/membership_collection_spec.rb @@ -110,6 +110,44 @@ its(:count) { should == 1 } its('direct.count') { should == 0 } end + + describe "when there are direct and indirect memberships" do + # @indirect_group + # |------------ @group + # | |------ @user1 + # | |------ @user2 + # | + # |------------ @group2 + # + before do + @group = create(:group, name: 'group') + @user1 = create(:user); @group.assign_user(@user1) + @user2 = create(:user); @group.assign_user(@user2) + @user = @user1 + @membership1 = Membership.where(user: @user1, group: @group).first + @membership2 = Membership.where(user: @user2, group: @group).first + @indirect_group = @group.parent_groups.create name: 'indirect_group' + @indirect_membership1 = Membership.where(user: @user1, group: @indirect_group).first + @indirect_membership2 = Membership.where(user: @user2, group: @indirect_group).first + @group2 = @indirect_group.child_groups.create + end + describe "when the selector group is a direct group" do + subject { Membership.where(group: @group, user: @user) } + its(:count) { should == 1 } + its(:first) { should == @membership1 } + its(:first) { should be_kind_of Membership } + its('first.group') { should == @group } + its('first.user') { should == @user } + end + describe "when the selector group is an indirect group" do + subject { Membership.where(group: @indirect_group, user: @user) } + its(:count) { should == 1 } + its(:first) { should == @indirect_membership1 } + its(:first) { should be_kind_of Membership } + its('first.group') { should == @indirect_group } + its('first.user') { should == @user } + end + end end end From b15e7797c2e4acb7a4b082eb9bbcad2c265c2766 Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Wed, 4 Nov 2015 14:25:20 +0100 Subject: [PATCH 31/42] caching: Setting the cache timestamp length according to the database. https://trello.com/c/jh6CpwdX/811-caching-zeit-auflosung --- app/models/cache_store_extension.rb | 60 +++++++++++++++++------------ app/models/membership.rb | 8 ++++ app/models/membership_collection.rb | 9 ++++- config/initializers/cache.rb | 40 +++++++++++++++++++ spec/models/cache_spec.rb | 41 ++++++++++++++++++++ 5 files changed, 131 insertions(+), 27 deletions(-) create mode 100644 config/initializers/cache.rb create mode 100644 spec/models/cache_spec.rb diff --git a/app/models/cache_store_extension.rb b/app/models/cache_store_extension.rb index 75d26398c..c13d46eb3 100644 --- a/app/models/cache_store_extension.rb +++ b/app/models/cache_store_extension.rb @@ -10,21 +10,31 @@ def uncached return result end - #def fetch(key, options = {}, &block) - # rescue_from_undefined_class_or_module do - # rescue_from_other_errors(block) do - # super(key, {force: @ignore_cache}.merge(options), &block) - # end - # end - #end + def fetch(key, options = {}, &block) + rescue_from_undefined_class_or_module do + rescue_from_too_big_to_marshal do + rescue_from_other_errors(block) do + super(key, {force: @ignore_cache}.merge(options), &block) + end + end + end + end def delete_regex(regex) if @data - keys = @data.keys.select { |key| key =~ regex } + keys = list_keys(regex) @data.del(*keys) if keys.count > 0 end end + def list_keys(regex) + regex = Regexp.new "#{regex.reload.cache_key}/*" if regex.kind_of? ActiveRecord::Base + @data.keys.select { |key| key =~ regex } + end + def ls(regex) + list_keys(regex) + end + # This autoloads classes or modules that are required to instanciate # the cached objects. # @@ -45,23 +55,23 @@ def rescue_from_undefined_class_or_module end private :rescue_from_undefined_class_or_module - # # This provides a solution to errors like - # # "year too big to marshal: 16 UTC". - # # - # # Note that this error confusingly does not neccessarily have - # # something to do with caching dates. - # # - # def rescue_from_too_big_to_marshal - # begin - # yield - # rescue ArgumentError, NameError => exc - # if exc.message.match(%r|year too big to marshal: (.+)|) - # yield.reload # Reloading the ActiveRecord objects can help. - # else - # raise exc - # end - # end - # end + # This provides a solution to errors like + # "year too big to marshal: 16 UTC". + # + # Note that this error confusingly does not neccessarily have + # something to do with caching dates. + # + def rescue_from_too_big_to_marshal + begin + yield + rescue ArgumentError, NameError => exc + if exc.message.match(%r|year too big to marshal: (.+)|) + yield.reload # Reloading the ActiveRecord objects can help. + else + raise exc + end + end + end def rescue_from_other_errors(block_without_fetch, &block_with_fetch) begin diff --git a/app/models/membership.rb b/app/models/membership.rb index 14eabac10..57893c143 100644 --- a/app/models/membership.rb +++ b/app/models/membership.rb @@ -76,6 +76,10 @@ def group_id def group_id=(new_group_id) group = Group.find new_group_id end + + def user_id + user.try(:id) + end def user_title user.try(:title) @@ -126,4 +130,8 @@ def self.create(params) valid_from: new_dag_link.valid_from, valid_to: new_dag_link.valid_to) end + def inspect + "Membership(user: #{user_id}, group: #{group_id})" + end + end \ No newline at end of file diff --git a/app/models/membership_collection.rb b/app/models/membership_collection.rb index bbd33d03a..2e0c85dd4 100644 --- a/app/models/membership_collection.rb +++ b/app/models/membership_collection.rb @@ -39,6 +39,11 @@ def first_per_group return self end + def uncached + @uncached = true + return self + end + # Join the validity ranges of indirect memberships. # # group1 @@ -57,7 +62,7 @@ def join_validity_ranges_of_indirect_memberships end def to_a - Rails.cache.fetch [cache_key, 'to_a'] do + Rails.cache.fetch [cache_key, 'to_a'], force: @uncached do memberships = [] memberships += find_all_direct_memberships unless @indirect unless @direct @@ -85,7 +90,7 @@ def groups GroupCollection.new(memberships: self) end - delegate :count, :first, :last, to: :to_a + delegate :count, :first, :last, :sort_by, to: :to_a delegate :map, :collect, :select, :detect, :each, to: :to_a def include?(*other_memberships) diff --git a/config/initializers/cache.rb b/config/initializers/cache.rb new file mode 100644 index 000000000..ac6318fcc --- /dev/null +++ b/config/initializers/cache.rb @@ -0,0 +1,40 @@ +# This configures the cache key length such that it can be persistent. +# +# +# ## Explanation +# +# A typical cache key of a User looks like this, where the last part +# comes from the User#updated_at timestamp. +# +# users/4190-20151103154958215857000 +# +# Unfortunately, after reloading the user from the database, the cache key +# is changed, because the timestamp is stored with less precision in the +# database as in memory. +# +# users/4190-20151103154958000000000 +# +# With our current database setup, the updated_at column is only stored with +# a precision of integer seconds. +# +# Therefore, we shorten the cache key to: +# +# users/4190-20151103154958 +# +# +# ## Cache Timestamp Formats +# +# Possible formats are defined at: `Time::DATE_FORMATS` +# http://api.rubyonrails.org/classes/Time.html +# +# :nsec (nanoseconds) +# :usec (microseconds) +# :number (seconds) <-- currently stored in database +# +Rails.application.config.active_record.cache_timestamp_format = :number + +class ActiveRecord::Base + def self.cache_timestamp_format + Rails.application.config.active_record.cache_timestamp_format + end +end \ No newline at end of file diff --git a/spec/models/cache_spec.rb b/spec/models/cache_spec.rb new file mode 100644 index 000000000..058b556e5 --- /dev/null +++ b/spec/models/cache_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' + +describe "cache" do + + describe "cache_timestamp_format" do + subject { User.cache_timestamp_format} + it { should == :number } + end + + describe "cache_key" do + before { @user = create :user } + subject { @user.cache_key } + its(:length) { should == "users/4190-20151103154958".length } + end + + describe "after storing the User#title in the cache" do + + class User + def title + cached { "#{self.personal_title} #{self.name}".strip } + end + end + before { @user = create :user; @user.title } + + describe "Rails.cache.ls (Rails.cache.list_keys)" do + subject { Rails.cache.ls @user } + its(:count) { should >= 1} + it { should include "#{@user.cache_key}/title" } + end + + describe "#delete_cache" do + subject { @user.delete_cache} + it "should remove all cache entries under the user's scope" do + Rails.cache.ls(@user).count.should >= 1 + subject + Rails.cache.ls(@user).count.should == 0 + end + end + end + +end \ No newline at end of file From 720fbc98f3a674e2635947704e5f3e8b276b184c Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Wed, 4 Nov 2015 21:29:40 +0100 Subject: [PATCH 32/42] connected groups: fixing model specs. --- app/models/concerns/group_memberships.rb | 5 ++++- spec/models/cache_spec.rb | 2 +- .../models/concerns/membership_validity_range_spec.rb | 11 +++++------ 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/app/models/concerns/group_memberships.rb b/app/models/concerns/group_memberships.rb index 3054104ac..1dfecd4d6 100644 --- a/app/models/concerns/group_memberships.rb +++ b/app/models/concerns/group_memberships.rb @@ -35,7 +35,10 @@ def build_membership def latest_memberships cached do - self.memberships.with_invalid.order_by(&:valid_from).last(10) + self.memberships.with_invalid + .select { |membership| membership.valid_from.present? } + .sort_by { |membership| membership.valid_from } + .last(10) end end diff --git a/spec/models/cache_spec.rb b/spec/models/cache_spec.rb index 058b556e5..3efecb4b8 100644 --- a/spec/models/cache_spec.rb +++ b/spec/models/cache_spec.rb @@ -10,7 +10,7 @@ describe "cache_key" do before { @user = create :user } subject { @user.cache_key } - its(:length) { should == "users/4190-20151103154958".length } + its(:length) { should == "users/#{@user.id}-20151103154958".length } end describe "after storing the User#title in the cache" do diff --git a/spec/models/concerns/membership_validity_range_spec.rb b/spec/models/concerns/membership_validity_range_spec.rb index 3aa551582..01027a758 100644 --- a/spec/models/concerns/membership_validity_range_spec.rb +++ b/spec/models/concerns/membership_validity_range_spec.rb @@ -10,10 +10,10 @@ # before do @group1 = create :group, name: 'group1' - @subgroup1 = @group1.child_groups.create name: 'group2' + @subgroup1 = @group1.child_groups.create name: 'subgroup1' @group2 = create :group, name: 'group2' - @user1 = create :user; @subgroup1 << @user1; @group2 << @user1 - @user2 = create :user; @group2 << @user2 + @user1 = create :user, last_name: 'user1'; @subgroup1 << @user1; @group2 << @user1 + @user2 = create :user, last_name: 'user2'; @group2 << @user2 end describe "#invalidate" do @@ -72,7 +72,7 @@ describe "for a current membership" do before do @membership = Membership.where(user: @user1, group: @subgroup1).first - @membership.dag_link.update_attribute :valid_from, 1.month.ago + @membership.dag_link.update_attributes valid_from: 1.month.ago @membership = Membership.where(user: @user1, group: @subgroup1).first end it { should be_true } @@ -80,8 +80,7 @@ describe "for a past membership" do before do @membership = Membership.where(user: @user1, group: @subgroup1).first - @membership.dag_link.update_attribute :valid_from, 1.month.ago - @membership.dag_link.update_attribute :valid_to, 10.days.ago + @membership.dag_link.update_attributes valid_from: 1.month.ago, valid_to: 10.days.ago @membership = Membership.where(user: @user1, group: @subgroup1).first end it { should be_false } From 794f18c1db690ccd790be8f9dce886f7e9d1a177 Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Wed, 4 Nov 2015 23:30:26 +0100 Subject: [PATCH 33/42] implementing Array#pluck as shortcut for Array#map&. This will come in Rails 5. --- app/models/array.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/models/array.rb b/app/models/array.rb index fd62583dc..d2d948e6b 100644 --- a/app/models/array.rb +++ b/app/models/array.rb @@ -12,4 +12,8 @@ def reload collect { |element| element.reload if element.respond_to?(:reload) } end + def pluck(attr_name) + map(&attr_name) + end + end \ No newline at end of file From 5ba6fa37f26c4f80565bdfc88a6616bff63e087f Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Wed, 4 Nov 2015 23:32:50 +0100 Subject: [PATCH 34/42] connected groups: removing the acts_as_dag gem. Only direct dag links are stored in the database as of this commit. --- Gemfile.lock | 4 - app/models/concerns/dag_link_repair.rb | 88 ------------------- app/models/dag_link.rb | 43 ++++++++- app/models/has_dag_links.rb | 87 ++++++++++++++++++ .../active_record_has_dag_links_extension.rb | 2 + lib/your_platform/engine.rb | 1 - your_platform.gemspec | 2 - 7 files changed, 129 insertions(+), 98 deletions(-) delete mode 100644 app/models/concerns/dag_link_repair.rb create mode 100644 app/models/has_dag_links.rb create mode 100644 config/initializers/active_record_has_dag_links_extension.rb diff --git a/Gemfile.lock b/Gemfile.lock index 410230aa3..850b196d4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -32,7 +32,6 @@ PATH remote: . specs: your_platform (1.0.1) - acts-as-dag (>= 2.5.7) acts_as_tree auto_html (>= 1.6.4) autosize-rails @@ -136,9 +135,6 @@ GEM minitest (~> 5.1) thread_safe (~> 0.3, >= 0.3.4) tzinfo (~> 1.1) - acts-as-dag (4.0.0) - activemodel - activerecord (~> 4.0, >= 4.0.0) acts_as_tree (2.2.0) activerecord (>= 3.0.0) addressable (2.3.8) diff --git a/app/models/concerns/dag_link_repair.rb b/app/models/concerns/dag_link_repair.rb deleted file mode 100644 index 00e1bb927..000000000 --- a/app/models/concerns/dag_link_repair.rb +++ /dev/null @@ -1,88 +0,0 @@ -concern :DagLinkRepair do - - class_methods do - - # This starts all automatic dag link repair operations. - # - def repair - RedundantLinkRepairer.scan_and_repair - end - - class RedundantLinkRepairer - - def self.scan_and_repair - self.new.scan_and_repair - end - - def scan_and_repair - mute_sql_log - scan - delete_redundant_links - recalculate_links - print "\n\nFinished.\n".blue - unmute_sql_log - end - - def mute_sql_log - @old_log_level = ActiveRecord::Base.logger.level - ActiveRecord::Base.logger.level = 1 - end - def unmute_sql_log - ActiveRecord::Base.logger.level = @old_log_level - end - - # There are cases when an indirect membership is represented by multiple dag links - # by error. We don't know how those issues arise, yet. This method scans for such - # occurances. - # - # Example: - # - # Alle Amtsträger - # |-------- Alle Seniores -------. - # | | - # | | - # |------ Alle Admins ------- User - # - # In this example, the link between "Alle Amtsträger" and "User" should be one - # DagLink(direct: false, count: 2). But, by error, there are two DagLink objects. - # - def scan - @occurances = [] - print "Scanning for redundant links.\n".blue - DagLink.where(direct: false).each do |link| - redundant_links = DagLink.where( - ancestor_type: link.ancestor_type, ancestor_id: link.ancestor_id, - descendant_type: link.descendant_type, descendant_id: link.descendant_id - ) - if redundant_links.count > 1 - @occurances << redundant_links - print "DATA CORRUPTION: REDUNDANT INDIRECT LINKS: #{redundant_links.inspect}\n\n".red - else - print ".".green - end - end - return @occurances - end - - def delete_redundant_links - print "\n\nRepairing redundant links.\n".blue - @occurances.each do |redundant_links| - redundant_links[1..-1].each do |redundant_link| # all links but the first, which is the original one - redundant_link.delete - print ".".blue - end - end - end - - def recalculate_links - print "\n\nRecalculating affected indirect validity ranges.\n".blue - @occurances.each do |redundant_links| - original_link = redundant_links[0].becomes UserGroupMembership - original_link.recalculate_validity_range_from_direct_memberships - original_link.save - print ".".blue - end - end - end - end -end \ No newline at end of file diff --git a/app/models/dag_link.rb b/app/models/dag_link.rb index db43d9803..f6f46527a 100644 --- a/app/models/dag_link.rb +++ b/app/models/dag_link.rb @@ -1,10 +1,40 @@ -# -*- coding: utf-8 -*- +# In a graph, the DagLinks are the links between the nodes. +# "DAG" stands for directed acyclic graph. +# +# The functionality is mostly extracted from the acts_as_dag gem, +# which we have used earlier: +# https://github.com/resgraph/acts-as-dag/blob/master/lib/dag/dag.rb +# +# Now, in contrast to the gem, we only store direct links in the database. +# Indirect links exist only in memory and in cache. This way, we don't have +# redundancies and inconsistencies anymore. +# class DagLink < ActiveRecord::Base attr_accessible :ancestor_id, :ancestor_type, :count, :descendant_id, :descendant_type, :direct if defined? attr_accessible - acts_as_dag_links polymorphic: true has_many_flags + + belongs_to :ancestor, :polymorphic => true + belongs_to :descendant, :polymorphic => true + + validates :ancestor_type, :presence => true + validates :descendant_type, :presence => true + + scope :with_ancestor, lambda { |ancestor| where(:ancestor_id => ancestor.id, :ancestor_type => ancestor.class.to_s) } + scope :with_descendant, lambda { |descendant| where(:descendant_id => descendant.id, :descendant_type => descendant.class.to_s) } + scope :with_ancestor_point, lambda { |point| where(:ancestor_id => point.id, :ancestor_type => point.type) } + scope :with_descendant_point, lambda { |point| where(:descendant_id => point.id, :descendant_type => point.type) } + + scope :ancestor_nodes, lambda { joins(:ancestor) } + scope :descendant_nodes, lambda { joins(:descendant) } + + validates :ancestor, :presence => true + validates :descendant, :presence => true + + before_validation :fill_defaults, :on => :update + before_validation :fill_defaults, :on => :create + # We have to workaround a bug in Rails 3 here. But, since Rails 3 is no longer fully supported, # this is not going to be fixed. # @@ -18,7 +48,6 @@ class DagLink < ActiveRecord::Base after_save { self.delay.delete_cache } before_destroy :delete_cache - include DagLinkRepair include DagLinkValidityRange def fill_cache @@ -31,4 +60,12 @@ def delete_cache descendant.try(:delete_cache) end + # These are defaults that are needed while migrating from the + # acts_as_dag gem to the new mechanism. + # + def fill_defaults + self.direct = true if self.direct.nil? + self.count = 0 if self.count.nil? + end + end diff --git a/app/models/has_dag_links.rb b/app/models/has_dag_links.rb new file mode 100644 index 000000000..a26b085da --- /dev/null +++ b/app/models/has_dag_links.rb @@ -0,0 +1,87 @@ +# This defines the ActiveRecord::Base method `has_dag_links`. +# +# Usage: +# +# class Group < ActiveRecord::Base +# has_dag_links ancestor_class_names: ['Group'], +# descendant_class_names: ['Group', 'User'] +# end +# +# This code is extracted from the acts_as_dag gem. +# https://github.com/resgraph/acts-as-dag/blob/master/lib/dag/dag.rb +# +module HasDagLinks + def has_dag_links(options = {}) + conf = { + :class_name => nil, + :ancestor_class_names => [], + :descendant_class_names => [] + } + conf.update(options) + + has_many :links_as_ancestor, :as => :ancestor, :class_name => 'DagLink' + has_many :links_as_descendant, :as => :descendant, :class_name => 'DagLink' + has_many :links_as_parent, lambda { where(:direct => true) }, :as => :ancestor, :class_name => 'DagLink' + has_many :links_as_child, lambda { where(:direct => true) }, :as => :descendant, :class_name => 'DagLink' + + ancestor_table_names = [] + parent_table_names = [] + conf[:ancestor_class_names].each do |class_name| + table_name = class_name.tableize + self.class_eval <<-EOL2 + has_many :links_as_descendant_for_#{table_name}, lambda { where(:ancestor_type => '#{class_name}') }, :as => :descendant, :class_name => 'DagLink' + has_many :ancestor_#{table_name}, :through => :links_as_descendant_for_#{table_name}, :source => :ancestor, :source_type => '#{class_name}' + has_many :links_as_child_for_#{table_name}, lambda { where(:ancestor_type => '#{class_name}', :direct => true) }, :as => :descendant, :class_name => 'DagLink' + has_many :parent_#{table_name}, :through => :links_as_child_for_#{table_name}, :source => :ancestor, :source_type => '#{class_name}' + def root_for_#{table_name}? + self.links_as_descendant_for_#{table_name}.empty? + end + EOL2 + ancestor_table_names << ('ancestor_'+table_name) + parent_table_names << ('parent_'+table_name) + unless conf[:descendant_class_names].include?(class_name) + #this apparently is only one way is we can create some aliases making things easier + self.class_eval "has_many :#{table_name}, :through => :links_as_descendant_for_#{table_name}, :source => :ancestor, :source_type => '#{class_name}'" + end + end + + self.class_eval <<-EOL25 + def ancestors + #{ancestor_table_names.join(' + ')} + end + def parents + #{parent_table_names.join(' + ')} + end + EOL25 + + descendant_table_names = [] + child_table_names = [] + conf[:descendant_class_names].each do |class_name| + table_name = class_name.tableize + self.class_eval <<-EOL3 + has_many :links_as_ancestor_for_#{table_name}, lambda { where(:descendant_type => '#{class_name}') }, :as => :ancestor, :class_name => 'DagLink' + has_many :descendant_#{table_name}, :through => :links_as_ancestor_for_#{table_name}, :source => :descendant, :source_type => '#{class_name}' + has_many :links_as_parent_for_#{table_name}, lambda { where(:descendant_type => '#{class_name}', :direct => true) }, :as => :ancestor, :class_name => 'DagLink' + has_many :child_#{table_name}, :through => :links_as_parent_for_#{table_name}, :source => :descendant, :source_type => '#{class_name}' + def leaf_for_#{table_name}? + self.links_as_ancestor_for_#{table_name}.empty? + end + EOL3 + descendant_table_names << ('descendant_'+table_name) + child_table_names << ('child_'+table_name) + unless conf[:ancestor_class_names].include?(class_name) + self.class_eval "has_many :#{table_name}, :through => :links_as_ancestor_for_#{table_name}, :source => :descendant, :source_type => '#{class_name}'" + end + end + + self.class_eval <<-EOL35 + def descendants + #{descendant_table_names.join(' + ')} + end + def children + #{child_table_names.join(' + ')} + end + EOL35 + + end +end \ No newline at end of file diff --git a/config/initializers/active_record_has_dag_links_extension.rb b/config/initializers/active_record_has_dag_links_extension.rb new file mode 100644 index 000000000..7b6119d0b --- /dev/null +++ b/config/initializers/active_record_has_dag_links_extension.rb @@ -0,0 +1,2 @@ +require "has_dag_links" +ActiveRecord::Base.extend HasDagLinks diff --git a/lib/your_platform/engine.rb b/lib/your_platform/engine.rb index dc8fb8c30..80db0a8a6 100644 --- a/lib/your_platform/engine.rb +++ b/lib/your_platform/engine.rb @@ -17,7 +17,6 @@ require 'jquery-atwho-rails' # Data Structures -require 'acts-as-dag' require 'acts_as_tree' # Caching diff --git a/your_platform.gemspec b/your_platform.gemspec index 5fdd38c8e..8fb08bbe6 100644 --- a/your_platform.gemspec +++ b/your_platform.gemspec @@ -48,8 +48,6 @@ Gem::Specification.new do |s| # Data Structures # Retry transactions: Rescue from deadlocks. s.add_dependency 'transaction_retry' - # DAG Structure, https://github.com/resgraph/acts-as-dag - s.add_dependency 'acts-as-dag', '>= 2.5.7' # MIT License s.add_dependency 'acts_as_tree' # MIT License # Caching From da98c55d37b8f65e30a1160d6223236f1c52dfec Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Wed, 4 Nov 2015 23:33:48 +0100 Subject: [PATCH 35/42] connected groups: migrating Corporation#status_groups --- .../concerns/structureable_connected_leaf_groups.rb | 11 +++++++++++ app/models/corporation.rb | 2 +- app/models/status_group.rb | 4 +--- app/models/structureable.rb | 1 + spec/models/corporation_spec.rb | 4 ++-- 5 files changed, 16 insertions(+), 6 deletions(-) create mode 100644 app/models/concerns/structureable_connected_leaf_groups.rb diff --git a/app/models/concerns/structureable_connected_leaf_groups.rb b/app/models/concerns/structureable_connected_leaf_groups.rb new file mode 100644 index 000000000..aa4fe6d50 --- /dev/null +++ b/app/models/concerns/structureable_connected_leaf_groups.rb @@ -0,0 +1,11 @@ +concern :StructureableConnectedLeafGroups do + + def connected_leaf_groups + cached do + connected_descendant_groups.select do |group| + group.connected_descendant_groups.count == 0 + end + end + end + +end \ No newline at end of file diff --git a/app/models/corporation.rb b/app/models/corporation.rb index 5efbfa54e..7042bd493 100644 --- a/app/models/corporation.rb +++ b/app/models/corporation.rb @@ -35,7 +35,7 @@ def self.create_corporations_parent_group def is_first_corporation_this_user_has_joined?( user ) return false if not user.groups.include? self return true if user.corporations.count == 1 - this_membership_valid_from = UserGroupMembership.find_by_user_and_group( user, self ).valid_from + this_membership_valid_from = Membership.where(user: user, group: self).first.valid_from user.memberships.each do |membership| return false if membership.valid_from.to_i < this_membership_valid_from.to_i end diff --git a/app/models/status_group.rb b/app/models/status_group.rb index aeb972d80..3ed25e7f7 100644 --- a/app/models/status_group.rb +++ b/app/models/status_group.rb @@ -7,9 +7,7 @@ class StatusGroup < Group def self.find_all_by_corporation(corporation) - corporation.leaf_groups.select do |group| - group.ancestor_events.count == 0 - end + corporation.connected_leaf_groups end def self.find_all_by_user(user, options = {}) diff --git a/app/models/structureable.rb b/app/models/structureable.rb index b1fafead0..3c316c6cb 100644 --- a/app/models/structureable.rb +++ b/app/models/structureable.rb @@ -72,6 +72,7 @@ def is_structureable( options = {} ) # Use the connected-groups mechanism. # include StructureableConnectedGroups + include StructureableConnectedLeafGroups end module StructureableInstanceMethods diff --git a/spec/models/corporation_spec.rb b/spec/models/corporation_spec.rb index cfc7b61ec..a6b1149a4 100644 --- a/spec/models/corporation_spec.rb +++ b/spec/models/corporation_spec.rb @@ -60,11 +60,11 @@ @another_corporation = create( :corporation ) @user = create( :user ) - @first_membership = UserGroupMembership.create( user: @user, group: @first_corporation ) + @first_membership = Membership.create(user: @user, group: @first_corporation) @first_membership.valid_from = 1.year.ago @first_membership.save - @second_membership = UserGroupMembership.create( user: @user, group: @second_corporation ) + @second_membership = Membership.create(user: @user, group: @second_corporation) end describe "for the corporation the user has joined first" do subject { @first_corporation.is_first_corporation_this_user_has_joined?( @user ) } From 37f1acef1734de5b8e6b2b98250c130b82e31810 Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Wed, 4 Nov 2015 23:35:55 +0100 Subject: [PATCH 36/42] connected groups: migrating Group#workflows --- app/models/concerns/group_workflows.rb | 40 ++++++++++++++++++++++++++ app/models/group.rb | 30 +------------------ app/models/workflow.rb | 8 ++++-- 3 files changed, 47 insertions(+), 31 deletions(-) create mode 100644 app/models/concerns/group_workflows.rb diff --git a/app/models/concerns/group_workflows.rb b/app/models/concerns/group_workflows.rb new file mode 100644 index 000000000..15b0578bc --- /dev/null +++ b/app/models/concerns/group_workflows.rb @@ -0,0 +1,40 @@ +# This handles the workflows associations of groups. +# +# These methods override the standard methods, which are usual ActiveRecord +# associations methods created by `HasDagLinks`. +# But since the `Workflow` in the main application inherits from +# `WorkflowKit::Workflow` and single table inheritance and polymorphic +# associations do not always work together as expected in rails, +# as can be seen here, http://stackoverflow.com/questions/9628610, +# we have to override these methods. +# +# ActiveRecord associations require 'WorkflowKit::Workflow' to be stored +# in the database's type column, but by asking for the `child_workflows` +# we want to get objects of the `Workflow` type, not `WorkflowKit::Workflow`, +# since Workflow objects may have additional methods, added by the main +# application. +# +concern :GroupWorkflows do + + def workflows + child_workflows + end + + def child_workflows + Workflow + .joins(:links_as_child) + .where(dag_links: {ancestor_type: 'Group', ancestor_id: self.id}) + .uniq + end + + def descendant_workflows + workflows_of_self_and_connected_groups + end + + def workflows_of_self_and_connected_groups + cached do + (workflows + connected_descendant_groups.collect(&:workflows)).flatten.uniq + end + end + +end \ No newline at end of file diff --git a/app/models/group.rb b/app/models/group.rb index 707e6a4f4..8e5815e1b 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -43,6 +43,7 @@ class Group < ActiveRecord::Base include GroupMixins::Import include GroupMailingLists include GroupDummyUsers + include GroupWorkflows after_create :import_default_group_structure # from GroupMixins::Import after_save { self.delay.delete_cache } @@ -121,35 +122,6 @@ def group_of_groups=(add_the_flag) # Associated Objects # ========================================================================================== - # Workflows - # ------------------------------------------------------------------------------------------ - - # These methods override the standard methods, which are usual ActiveRecord associations - # methods created by the acts-as-dag gem - # (https://github.com/resgraph/acts-as-dag/blob/master/lib/dag/dag.rb). - # But since the Workflow in the main application - # inherits from WorkflowKit::Workflow and single table inheritance and polymorphic - # associations do not always work together as expected in rails, as can be seen here - # http://stackoverflow.com/questions/9628610/why-polymorphic-association-doesnt-work-for-sti-if-type-column-of-the-polymorph, - # we have to override these methods. - # - # ActiveRecord associations require 'WorkflowKit::Workflow' to be stored in the database's - # type column, but by asking for the `child_workflows` we want to get òbjects of the - # `Workflow` type, not `WorkflowKit::Workflow`, since Workflow objects may have - # additional methods, added by the main application. - # - def descendant_workflows - Workflow - .joins( :links_as_descendant ) - .where( :dag_links => { :ancestor_type => "Group", :ancestor_id => self.id } ) - .uniq - end - - def child_workflows - self.descendant_workflows.where( :dag_links => { direct: true } ) - end - - # Events # ------------------------------------------------------------------------------------------ diff --git a/app/models/workflow.rb b/app/models/workflow.rb index 22b20d317..12adf77c5 100644 --- a/app/models/workflow.rb +++ b/app/models/workflow.rb @@ -16,8 +16,12 @@ def name_as_verb .downcase end - def wah_group # => TODO: corporation - ( self.ancestor_groups & Corporation.all ).first + def corporation + (self.ancestor_groups & Corporation.all).first + end + + def ancestor_groups + connected_ancestor_groups end def self.find_or_create_mark_as_deceased_workflow From db4553b88faf33503e80cd7bef74bae4b1d5f528 Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Mon, 9 Nov 2015 00:27:24 +0100 Subject: [PATCH 37/42] connected groups: implementing Structureable#connected_descendant_pages --- .../concerns/structureable_connected_pages.rb | 25 ++++++++++++++++ app/models/structureable.rb | 1 + .../structureable_connected_pages_spec.rb | 29 +++++++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 app/models/concerns/structureable_connected_pages.rb create mode 100644 spec/models/concerns/structureable_connected_pages_spec.rb diff --git a/app/models/concerns/structureable_connected_pages.rb b/app/models/concerns/structureable_connected_pages.rb new file mode 100644 index 000000000..30b66377e --- /dev/null +++ b/app/models/concerns/structureable_connected_pages.rb @@ -0,0 +1,25 @@ +# This determines which pages are directly connected, i.e. associated with the structureable. +# This means that the pages are not separated by a group or another object from the +# structureable. +# +# Example: +# +# @group +# | +# @page --- @subpage <--- connected to @group +# | +# @disconnected_group +# | +# @disconnected_group_page <--- not connected to @group +# +concern :StructureableConnectedPages do + + def connected_descendant_pages + Page.find connected_descendant_page_ids + end + + def connected_descendant_page_ids + cached { self.child_pages.collect { |child_page| [child_page.id] + child_page.connected_descendant_page_ids }.flatten } + end + +end \ No newline at end of file diff --git a/app/models/structureable.rb b/app/models/structureable.rb index 3c316c6cb..85be2cd44 100644 --- a/app/models/structureable.rb +++ b/app/models/structureable.rb @@ -73,6 +73,7 @@ def is_structureable( options = {} ) # include StructureableConnectedGroups include StructureableConnectedLeafGroups + include StructureableConnectedPages end module StructureableInstanceMethods diff --git a/spec/models/concerns/structureable_connected_pages_spec.rb b/spec/models/concerns/structureable_connected_pages_spec.rb new file mode 100644 index 000000000..c30e6a4be --- /dev/null +++ b/spec/models/concerns/structureable_connected_pages_spec.rb @@ -0,0 +1,29 @@ +require 'spec_helper' + +describe StructureableConnectedPages do + + # + # @group + # | + # @page --- @subpage + # | + # @disconnected_group + # | + # @disconnected_group_page + # + before do + @group = create :group, name: 'group' + @page = @group.child_pages.create name: 'page' + @subpage = @page.child_pages.create name: 'subpage' + @disconnected_group = @subpage.child_groups.create name: 'disconnected_group' + @disconnected_group_page = @disconnected_group.child_pages.create name: 'disconnected_group_page' + end + + describe "#conncted_descendant_pages" do + subject { @group.connected_descendant_pages } + it { should include @page } + it { should include @subpage } + it { should_not include @disconnected_group_page } + end + +end \ No newline at end of file From fdad07102e90eb999481a2bb7c7495278aeb8065 Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Mon, 9 Nov 2015 00:31:15 +0100 Subject: [PATCH 38/42] connected groups: working on graph caching: Implementing `Structureable#affected_nodes_after_officer_has_changed`. This is needed for refreshing the cache of affected nodes when a part of the graph changes. --- .../concerns/structureable_graph_cache.rb | 57 +++++++++++++++ app/models/structureable.rb | 4 ++ .../structureable_graph_cache_spec.rb | 70 +++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 app/models/concerns/structureable_graph_cache.rb create mode 100644 spec/models/concerns/structureable_graph_cache_spec.rb diff --git a/app/models/concerns/structureable_graph_cache.rb b/app/models/concerns/structureable_graph_cache.rb new file mode 100644 index 000000000..3ad450a74 --- /dev/null +++ b/app/models/concerns/structureable_graph_cache.rb @@ -0,0 +1,57 @@ +# A lot of methods, which have a result that depends on the graph, +# are cached. +# +# class GraphNode +# is_structureable +# +# def some_method_depending_on_the_graph +# cached { calculate_result(...) } +# end +# end +# +# But this means that the graph-related cache has to be re-calculated +# whenever the graph changes. We can't re-calculate the whole graph +# whenever some small part changes, since this would be too expensive. +# +# Instead, the methods in this file determine which parts of the grpah +# are affacted by a change, and, which caches are to be re-calculated +# in response. +# +concern :StructureableGraphCache do + + concerning :OfficerHasChanged do + def refresh_cache_after_officer_has_changed + end + + def affected_nodes_after_officer_has_changed + ([self] + connected_descendant_groups).collect do |structureable| + [structureable] + structureable.connected_descendant_pages + structureable.child_events + structureable.child_users + end.flatten + end + end + + concerning :MembershipHasChanged do + def refresh_cache_after_membership_has_changed + end + + def affected_nodes_after_membership_has_changed + end + end + + concerning :SubgroupHasChanged do + def refresh_cache_after_subgroup_has_changed + end + + def affected_nodes_after_subgroup_has_changed + end + end + + concerning :SupergroupHasChanged do + def refresh_cache_after_supergroup_has_changed + end + + def affected_nodes_after_supergroup_has_changed + end + end + +end \ No newline at end of file diff --git a/app/models/structureable.rb b/app/models/structureable.rb index 85be2cd44..a4a1d96c3 100644 --- a/app/models/structureable.rb +++ b/app/models/structureable.rb @@ -74,6 +74,10 @@ def is_structureable( options = {} ) include StructureableConnectedGroups include StructureableConnectedLeafGroups include StructureableConnectedPages + + # Methods to manage the graph-related cache. + # + include StructureableGraphCache end module StructureableInstanceMethods diff --git a/spec/models/concerns/structureable_graph_cache_spec.rb b/spec/models/concerns/structureable_graph_cache_spec.rb new file mode 100644 index 000000000..062acb291 --- /dev/null +++ b/spec/models/concerns/structureable_graph_cache_spec.rb @@ -0,0 +1,70 @@ +require 'spec_helper' + +describe StructureableGraphCache do + + # + # @root_page + # | + # @intranet_root_page + # | + # @everyone_group + # | + # @corporations_group + # |------------------ @berlin_corporation + # | + # @erlangen_corporation + # |------------------ @philisterschaft_group + # | + # @aktivitas_group + # |------------------ @fuxen_group --------- @fux_user + # |------------------ @burschen_group ------ @burschen_protokolle_page + # |------------------ @protokolle_pages ---- @protokolle_sub_page + # | + # @officers_parent_group + # | + # @chargen_group + # | + # @senior_group + # + before do + @root_page = Page.find_root + @intranet_root_page = Page.find_intranet_root; @root_page << @intranet_root_page + @everyone_group = Group.everyone; @intranet_root_page << @everyone_group + @corporations_group = Group.corporations_parent; @everyone_group << @corporations_group + @berlin_corporation = create :corporation, name: "Berlin" + @erlangen_corporation = create :corporation, name: "Erlangen" + @philisterschaft_group = @erlangen_corporation.child_groups.create name: "Philisterschaft" + @aktivitas_group = @erlangen_corporation.child_groups.create name: "Aktivitas" + @fuxen_group = @aktivitas_group.child_groups.create name: "Fuxen" + @burschen_group = @aktivitas_group.child_groups.create name: "Burschen" + @protokolle_page = @aktivitas_group.child_pages.create title: "Protokolle" + @protokolle_sub_page = @protokolle_page.child_pages.create title: "Protokolle WS 2015/16" + @burschen_protokolle_page = @burschen_group.child_pages.create title: 'burschen_protokolle_page' + @officers_parent_group = @aktivitas_group.officers_parent + @chargen_group = @officers_parent_group.child_groups.create name: "Chargen" + @senior_group = @chargen_group.child_groups.create name: "Chargen" + @fux_user = create :user; @fuxen_group.assign_user @fux_user + end + + describe "requirements" do + describe "@everyone_group.connected_descendant_groups" do + subject { @everyone_group.connected_descendant_groups } + it { should include @corporations_group, @berlin_corporation, @erlangen_corporation, @aktivitas_group, @philisterschaft_group, @fuxen_group, @burschen_group } + it { should_not include @chargen_group, @senior_group } + it { should_not include @officers_parent_group } + end + end + + describe "#affected_nodes_after_officer_has_changed" do + subject { @aktivitas_group.affected_nodes_after_officer_has_changed } + it { should include @fuxen_group, @burschen_group } + it { should include @protokolle_page, @protokolle_sub_page } + it { should include @burschen_protokolle_page } + it { should_not include @everyone_group, @corporations_group, @erlangen_corporation, @berlin_corporation } + it { should_not include @philisterschaft_group } + it { should_not include @root_page, @intranet_root_page } + it { should_not include @officers_parent_group, @chargen_group, @senior_group } + it { should include @fux_user } + end + +end \ No newline at end of file From 367d37762a45ae2f9bcd7b97abfa932219cc3ccf Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Thu, 12 Nov 2015 07:49:29 +0100 Subject: [PATCH 39/42] connected groups: working on graph cache refreshments --- app/models/active_record_cache_extension.rb | 6 +++ .../structureable_connected_groups.rb | 10 +++++ .../structureable_connected_leaf_groups.rb | 4 ++ .../concerns/structureable_graph_cache.rb | 22 +++++---- app/models/dag_link.rb | 2 + app/models/group.rb | 24 ++++------ app/models/structureable_mixins/roles.rb | 45 ++++++++++--------- .../structureable_graph_cache_spec.rb | 24 ++++++++++ spec/models/group_spec.rb | 41 +++-------------- 9 files changed, 98 insertions(+), 80 deletions(-) diff --git a/app/models/active_record_cache_extension.rb b/app/models/active_record_cache_extension.rb index 68ae6ba5a..d12d69ba6 100644 --- a/app/models/active_record_cache_extension.rb +++ b/app/models/active_record_cache_extension.rb @@ -103,6 +103,12 @@ def delete_cached(method_name) Rails.cache.delete_matched "#{self.cache_key}/#{method_name}/*" end + def refresh_cached(method_name) + self.delete_cached method_name + self.send method_name + return self + end + def bulk_delete_cached(method_name, objects) ids = objects.map &:id regex = /.*\/(#{ids.join('|')})(-.*|)\/#{method_name}.*/ diff --git a/app/models/concerns/structureable_connected_groups.rb b/app/models/concerns/structureable_connected_groups.rb index 7e9f0eeda..ceb55a60c 100644 --- a/app/models/concerns/structureable_connected_groups.rb +++ b/app/models/concerns/structureable_connected_groups.rb @@ -39,6 +39,16 @@ def connected_descendant_group_ids cached { select_connected_groups(child_groups).collect { |child_group| [child_group.id] + child_group.connected_descendant_group_ids }.flatten.uniq } end + def ancestor_groups(reload = false) + @ancestor_groups = nil if reload + @ancestor_groups ||= connected_ancestor_groups + end + + def descendant_groups(reload = false) + @descendant_groups = nil if reload + @descendant_groups ||= connected_descendant_groups + end + private def select_connected_groups(groups) diff --git a/app/models/concerns/structureable_connected_leaf_groups.rb b/app/models/concerns/structureable_connected_leaf_groups.rb index aa4fe6d50..2db597718 100644 --- a/app/models/concerns/structureable_connected_leaf_groups.rb +++ b/app/models/concerns/structureable_connected_leaf_groups.rb @@ -1,5 +1,9 @@ concern :StructureableConnectedLeafGroups do + def leaf_groups + connected_leaf_groups + end + def connected_leaf_groups cached do connected_descendant_groups.select do |group| diff --git a/app/models/concerns/structureable_graph_cache.rb b/app/models/concerns/structureable_graph_cache.rb index 3ad450a74..850e33e40 100644 --- a/app/models/concerns/structureable_graph_cache.rb +++ b/app/models/concerns/structureable_graph_cache.rb @@ -21,6 +21,11 @@ concerning :OfficerHasChanged do def refresh_cache_after_officer_has_changed + affected_nodes_after_officer_has_changed + .refresh_cached :find_admins + .refresh_cached :officers_of_self_and_parent_groups + + # TODO: `refresh_role_cache` end def affected_nodes_after_officer_has_changed @@ -32,25 +37,26 @@ def affected_nodes_after_officer_has_changed concerning :MembershipHasChanged do def refresh_cache_after_membership_has_changed + affected_nodes_after_membership_has_changed + .refresh_cached :members + .refresh_cached :memberships end def affected_nodes_after_membership_has_changed + connected_ancestor_groups end end concerning :SubgroupHasChanged do def refresh_cache_after_subgroup_has_changed + affected_nodes_after_subgroup_has_changed + .refresh_cached :connected_descendant_groups + .refresh_cached :members + .refresh_cached :memberships end def affected_nodes_after_subgroup_has_changed - end - end - - concerning :SupergroupHasChanged do - def refresh_cache_after_supergroup_has_changed - end - - def affected_nodes_after_supergroup_has_changed + connected_ancestor_groups end end diff --git a/app/models/dag_link.rb b/app/models/dag_link.rb index f6f46527a..44b4fd83f 100644 --- a/app/models/dag_link.rb +++ b/app/models/dag_link.rb @@ -58,6 +58,8 @@ def delete_cache super ancestor.try(:delete_cache) descendant.try(:delete_cache) + ancestor.connected_ancestor_groups.each { |g| g.delete_cached :connected_descendant_group_ids } + descendant.connected_descendant_groups.each { |g| g.delete_cached :connected_ancestor_group_ids } if descendant.kind_of? Group end # These are defaults that are needed while migrating from the diff --git a/app/models/group.rb b/app/models/group.rb index 8e5815e1b..501ead6e8 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -162,8 +162,8 @@ def cached_members_postal_addresses_created_at # Groups # ------------------------------------------------------------------------------------------ - def descendant_groups_by_name( descendant_group_name ) - self.descendant_groups.where( :name => descendant_group_name ) + def descendant_groups_by_name(descendant_group_name) + self.descendant_groups.select { |g| g.name == descendant_group_name } end def corporation @@ -172,31 +172,25 @@ def corporation end end def corporation_id - (([self.id] + ancestor_group_ids) & Corporation.pluck(:id)).first + (([self.id] + connected_ancestor_group_ids) & Corporation.pluck(:id)).first end def corporation? kind_of? Corporation end - # This returns all sub-groups of the corporation that have no - # sub-groups of their ownes except for officer groups. - # This is needed for the selection of status groups. - # - def leaf_groups - cached do - self.descendant_groups.order('id').includes(:flags).select do |group| - group.has_no_subgroups_other_than_the_officers_parent? and not group.is_officers_group? - end - end - end - def find_deceased_members_parent_group self.descendant_groups.where(name: ["Verstorbene", "Deceased"]).limit(1).first end def deceased find_deceased_members_parent_group end + + concerning :GroupDescendantUsers do + def descendant_users + raise('Changed interface! Please use `Group#members` or `Group#members.with_past`.') + end + end end diff --git a/app/models/structureable_mixins/roles.rb b/app/models/structureable_mixins/roles.rb index 8d26845d1..3f088bc67 100644 --- a/app/models/structureable_mixins/roles.rb +++ b/app/models/structureable_mixins/roles.rb @@ -34,26 +34,29 @@ def delete_cache end def delete_caches_concerning_roles - if self.class.base_class.name == 'Group' - # For an admins_parent, this is called recursively until the original group - # is reached. - # - # group - # |---- officers_parent - # |------------ admins_parent - # |------------ some officer group - # - if has_flag?(:officers_parent) || has_flag?(:admins_parent) - parent_groups.each do |group| - group.delete_cache - if group.descendants.count > 0 - bulk_delete_cached :admins_of_ancestors, group.descendants - bulk_delete_cached :admins_of_self_and_ancestors, group.descendants - bulk_delete_cached "*officers*", group.descendants - end - end - end - end + + # TODO: + + ### if self.class.base_class.name == 'Group' + ### # For an admins_parent, this is called recursively until the original group + ### # is reached. + ### # + ### # group + ### # |---- officers_parent + ### # |------------ admins_parent + ### # |------------ some officer group + ### # + ### if has_flag?(:officers_parent) || has_flag?(:admins_parent) + ### parent_groups.each do |group| + ### group.delete_cache + ### if group.descendants.count > 0 + ### bulk_delete_cached :admins_of_ancestors, group.descendants + ### bulk_delete_cached :admins_of_self_and_ancestors, group.descendants + ### bulk_delete_cached "*officers*", group.descendants + ### end + ### end + ### end + ### end end @@ -74,7 +77,7 @@ def find_officers_parent_group end def create_officers_parent_group - if self.ancestor_groups(true).find_all_by_flag(:officers_parent).count == 0 and not self.has_flag?(:officers_parent) + if self.connected_ancestor_groups.detect { |g| g.has_flag? :officers_parent }.nil? and not self.has_flag?(:officers_parent) # Do not allow officer cascades. create_special_group(:officers_parent) end diff --git a/spec/models/concerns/structureable_graph_cache_spec.rb b/spec/models/concerns/structureable_graph_cache_spec.rb index 062acb291..6ecafb25c 100644 --- a/spec/models/concerns/structureable_graph_cache_spec.rb +++ b/spec/models/concerns/structureable_graph_cache_spec.rb @@ -17,6 +17,8 @@ # | # @aktivitas_group # |------------------ @fuxen_group --------- @fux_user + # | |-------------- @fuxen_subgroup + # | # |------------------ @burschen_group ------ @burschen_protokolle_page # |------------------ @protokolle_pages ---- @protokolle_sub_page # | @@ -36,6 +38,7 @@ @philisterschaft_group = @erlangen_corporation.child_groups.create name: "Philisterschaft" @aktivitas_group = @erlangen_corporation.child_groups.create name: "Aktivitas" @fuxen_group = @aktivitas_group.child_groups.create name: "Fuxen" + @fuxen_subgroup = @fuxen_group.child_groups.create name: 'fuxen_subgroup' @burschen_group = @aktivitas_group.child_groups.create name: "Burschen" @protokolle_page = @aktivitas_group.child_pages.create title: "Protokolle" @protokolle_sub_page = @protokolle_page.child_pages.create title: "Protokolle WS 2015/16" @@ -67,4 +70,25 @@ it { should include @fux_user } end + describe "#affected_nodes_after_membership_has_changed" do + subject { @fuxen_group.affected_nodes_after_membership_has_changed } + it { should_not include @fuxen_group } + it { should include @aktivitas_group, @erlangen_corporation, @corporations_group, @everyone_group } + it { should_not include @burschen_group, @philisterschaft_group, @berlin_corporation } + it { should_not include @protokolle_page, @root_page, @intranet_root_page } + it { should_not include @officers_parent_group, @chargen_group, @senior_group } + it { should_not include @fux_user } + end + + describe "#affected_nodes_after_subgroup_has_changed" do + subject { @fuxen_group.affected_nodes_after_subgroup_has_changed } + it { should_not include @fuxen_group } + it { should include @aktivitas_group, @erlangen_corporation, @corporations_group, @everyone_group } + it { should_not include @burschen_group, @philisterschaft_group, @berlin_corporation } + it { should_not include @protokolle_page, @root_page, @intranet_root_page } + it { should_not include @officers_parent_group, @chargen_group, @senior_group } + it { should_not include @fux_user } + it { should_not include @fuxen_subgroup } + end + end \ No newline at end of file diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 56eef0778..a549ab43c 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -53,6 +53,11 @@ # ------------------------------------------------------------------------------------------ describe "(Workflows)" do + # + # @group + # |---- @subgroup --- @subworkflow + # |---- @workflow + # before do @group = create( :group ) @subgroup = create( :group ) @@ -115,42 +120,6 @@ end - # Users - # ------------------------------------------------------------------------------------------ - - describe "(Users)" do - - before do - @user = create( :user ) - @group = create( :group ) - @subgroup = create( :group ); @group.child_groups << @subgroup - end - - describe "#descendant_users" do - describe "for usual groups" do - before { @user.parent_groups << @subgroup } - subject { @group.descendant_users } - - it "should return all descendant users, including the users of the subgroups" do - subject.should include( @user ) - end - end - end - - describe "#child_users" do - describe "for usual groups" do - before { @user.parent_groups << @group } - subject { @group.child_users } - - it "should return all child users" do - subject.should include( @user ) - end - end - end - - end - - # Groups # ------------------------------------------------------------------------------------------ From 4350a09c2050f01e9b43308e182e4fd010f7337b Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Tue, 12 Apr 2016 12:41:41 +0200 Subject: [PATCH 40/42] working on graph performance spec --- Gemfile.lock | 96 ++++--- .../structureable_connected_groups.rb | 28 +- app/models/dag_link.rb | 26 +- app/models/structureable.rb | 80 ++---- spec/models/graph_performance_spec.rb | 263 ++++++++++++++++++ your_platform.gemspec | 49 ++-- 6 files changed, 396 insertions(+), 146 deletions(-) create mode 100644 spec/models/graph_performance_spec.rb diff --git a/Gemfile.lock b/Gemfile.lock index 850b196d4..2d900006f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -88,6 +88,7 @@ PATH slim_breadcrumb (>= 0.0.3) strong_parameters sugar-rails + table-formatter to_xls transaction_retry turboboost @@ -135,21 +136,22 @@ GEM minitest (~> 5.1) thread_safe (~> 0.3, >= 0.3.4) tzinfo (~> 1.1) - acts_as_tree (2.2.0) + acts_as_tree (2.4.0) activerecord (>= 3.0.0) addressable (2.3.8) ambry (0.3.1) arel (6.0.0) - auto_html (1.6.4) - redcarpet (~> 3.1) - rinku (~> 1.5.0) - autoprefixer-rails (6.0.3) + auto_html (2.0.0) + gemoji (~> 2.1) + redcarpet (~> 3.3) + rinku (~> 1.7) + tag_helper (~> 0.5) + autoprefixer-rails (6.3.6) execjs - json autosize-rails (1.18.17) rails (>= 3.1) - bcrypt (3.1.10) - best_in_place (3.0.3) + bcrypt (3.1.11) + best_in_place (3.1.0) actionpack (>= 3.2) railties (>= 3.2) binding_of_caller (0.7.2) @@ -175,7 +177,7 @@ GEM rack (>= 1.0.0) rack-test (>= 0.5.4) xpath (~> 2.0) - carrierwave (0.10.0) + carrierwave (0.11.0) activemodel (>= 3.2.0) activesupport (>= 3.2.0) json (>= 1.7) @@ -196,6 +198,7 @@ GEM execjs coffee-script-source (1.9.1.1) colored (1.2) + concurrent-ruby (1.0.1) connection_pool (2.2.0) coveralls (0.7.12) multi_json (~> 1.10) @@ -206,7 +209,7 @@ GEM daemons (1.2.3) database_cleaner (1.4.1) debug_inspector (0.0.2) - devise (3.5.2) + devise (3.5.6) bcrypt (~> 3.0) orm_adapter (~> 0.1) railties (>= 3.2.6, < 5) @@ -221,9 +224,9 @@ GEM jquery-rails jquery-turbolinks rails (>= 3.2) - em-hiredis (0.3.0) + em-hiredis (0.3.1) eventmachine (~> 1.0) - hiredis (~> 0.5.0) + hiredis (~> 0.6.0) em-websocket (0.3.8) addressable (>= 2.1.1) eventmachine (>= 0.12.9) @@ -231,14 +234,14 @@ GEM launchy (~> 2.1) mail (~> 2.2) erubis (2.7.0) - eventmachine (1.0.8) + eventmachine (1.0.9.1) execjs (2.6.0) factory_girl (4.5.0) activesupport (>= 3.0.0) factory_girl_rails (4.5.0) factory_girl (~> 4.5.0) railties (>= 3.0.0) - faker (1.5.0) + faker (1.6.3) i18n (~> 0.5) fastercsv (1.5.5) ffi (1.9.8) @@ -261,13 +264,13 @@ GEM foreman (0.78.0) thor (~> 0.19.1) formatador (0.2.5) - formtastic (3.1.3) + formtastic (3.1.4) actionpack (>= 3.2.13) fuubar (1.3.3) rspec (>= 2.14.0, < 3.1.0) ruby-progressbar (~> 1.4) gemoji (2.1.0) - geocoder (1.2.11) + geocoder (1.3.2) globalid (0.3.5) activesupport (>= 4.1.0) gravatar_image_tag (1.2.0) @@ -293,13 +296,13 @@ GEM tilt highline (1.6.21) hike (1.2.3) - hiredis (0.5.2) + hiredis (0.6.1) hitimes (1.2.2) http-cookie (1.0.2) domain_name (~> 0.5) i18n (0.7.0) - i18n-js (3.0.0.rc11) - i18n (~> 0.6) + i18n-js (3.0.0.rc12) + i18n (~> 0.6, >= 0.6.6) icalendar (2.3.0) jbuilder (2.2.12) activesupport (>= 3.0.0, < 5) @@ -324,7 +327,7 @@ GEM jquery-ui-rails (4.2.1) railties (>= 3.2.16) json (1.8.3) - judge (2.1.0) + judge (2.1.1) rails (>= 3.1) kgio (2.9.3) launchy (2.4.3) @@ -339,11 +342,11 @@ GEM loofah (2.0.3) nokogiri (>= 1.5.9) lumberjack (1.0.9) - merit (2.3.2) + merit (2.3.3) ambry (~> 0.3.0) method_source (0.8.2) mime-types (1.25.1) - mini_magick (4.3.6) + mini_magick (4.5.1) mini_portile (0.6.2) minitest (5.8.1) multi_json (1.11.2) @@ -358,7 +361,7 @@ GEM orm_adapter (0.5.0) passgen (1.0.2) pdf-core (0.5.1) - phony (2.15.6) + phony (2.15.20) poltergeist (1.6.0) capybara (~> 2.1) cliver (~> 0.3.1) @@ -371,14 +374,14 @@ GEM coderay (~> 1.1.0) method_source (~> 0.8.1) slop (~> 3.4) - public_activity (1.4.2) + public_activity (1.4.3) actionpack (>= 3.0.0) activerecord (>= 3.0) i18n (>= 0.5.0) railties (>= 3.0.0) rack (1.6.4) - rack-mini-profiler (0.9.7) - rack (>= 1.1.3) + rack-mini-profiler (0.9.9.2) + rack (>= 1.2.0) rack-protection (1.5.3) rack rack-ssl (1.4.1) @@ -404,7 +407,7 @@ GEM rails-deprecated_sanitizer (>= 1.0.1) rails-html-sanitizer (1.0.2) loofah (~> 2.0) - rails-i18n (4.0.6) + rails-i18n (4.0.8) i18n (~> 0.7) railties (~> 4.0) rails-settings-cached (0.4.1) @@ -422,16 +425,14 @@ GEM rdoc (4.2.0) json (~> 1.4) redcarpet (3.3.2) - redis (3.2.1) + redis (3.2.2) redis-actionpack (4.0.1) actionpack (~> 4) redis-rack (~> 1.5.0) redis-store (~> 1.1.0) - redis-activesupport (4.1.4) + redis-activesupport (4.1.5) activesupport (>= 3, < 5) redis-store (~> 1.1.0) - redis-namespace (1.5.2) - redis (~> 3.0, >= 3.0.4) redis-rack (1.5.0) rack (~> 1.5) redis-store (~> 1.1.0) @@ -446,13 +447,13 @@ GEM mime-types rest-client (~> 1.8) sinatra (~> 1.4.5) - responders (2.1.0) - railties (>= 4.2.0, < 5) + responders (2.1.2) + railties (>= 4.2.0, < 5.1) rest-client (1.8.0) http-cookie (>= 1.0.2, < 2.0) mime-types (>= 1.16, < 3.0) netrc (~> 0.7) - rinku (1.5.1) + rinku (1.7.3) rspec (2.14.1) rspec-core (~> 2.14.0) rspec-expectations (~> 2.14.0) @@ -471,7 +472,7 @@ GEM rspec-mocks (~> 2.14.0) rspec-rerun (0.3.0) rspec - ruby-ole (1.2.11.8) + ruby-ole (1.2.12) ruby-progressbar (1.7.5) ruby2ruby (2.1.3) ruby_parser (~> 3.1) @@ -489,21 +490,19 @@ GEM rdoc (~> 4.0) sexp_processor (4.5.0) shellany (0.0.1) - sidekiq (3.4.2) - celluloid (~> 0.16.0) + sidekiq (4.1.1) + concurrent-ruby (~> 1.0) connection_pool (~> 2.2, >= 2.2.0) - json (~> 1.0) redis (~> 3.2, >= 3.2.1) - redis-namespace (~> 1.5, >= 1.5.2) - sidekiq-limit_fetch (2.4.2) - sidekiq (>= 2.6.5, < 4.0) + sidekiq-limit_fetch (3.1.0) + sidekiq (>= 4) simplecov (0.9.2) docile (~> 1.1.0) multi_json (~> 1.0) simplecov-html (~> 0.9.0) simplecov-html (0.9.0) - sinatra (1.4.6) - rack (~> 1.4) + sinatra (1.4.7) + rack (~> 1.5) rack-protection (~> 1.4) tilt (>= 1.3, < 3) slim_breadcrumb (0.0.3) @@ -513,7 +512,7 @@ GEM sass-rails slop (3.6.0) spork (0.9.2) - spreadsheet (1.0.8) + spreadsheet (1.1.2) ruby-ole (>= 1.0) spring (1.3.3) sprockets (2.12.4) @@ -531,6 +530,8 @@ GEM railties (>= 3.2.0) sugar-rails (1.4.1) railties (>= 3.0.0) + table-formatter (0.2.0) + tag_helper (0.5.0) term-ansicolor (1.3.0) tins (~> 1.0) terminal-table (1.4.5) @@ -578,7 +579,7 @@ GEM kgio (~> 2.6) rack raindrops (~> 0.7) - warden (1.2.3) + warden (1.2.6) rack (>= 1.0) web-console (2.1.3) activemodel (>= 4.0) @@ -593,7 +594,7 @@ GEM eventmachine (~> 1.0.0.beta.4) rack thin - will_paginate (3.0.7) + will_paginate (3.1.0) xpath (2.0.0) nokogiri (~> 1.3) yajl-ruby (1.2.1) @@ -649,3 +650,6 @@ DEPENDENCIES workflow_kit! yard your_platform! + +BUNDLED WITH + 1.11.2 diff --git a/app/models/concerns/structureable_connected_groups.rb b/app/models/concerns/structureable_connected_groups.rb index ceb55a60c..10ebbc4ef 100644 --- a/app/models/concerns/structureable_connected_groups.rb +++ b/app/models/concerns/structureable_connected_groups.rb @@ -3,13 +3,13 @@ # but not via events or other non-group objects. # # Example: -# +# # group1 # |---- group2 --- group3 -------------- # |---- event1 | # | |------ attendees_group ---- user1 # | -# officers_parent ---- officer_group --- user2 +# officers_parent ---- officer_group --- user2 # # In the example, groups 1, 2, and 3 are connected groups. But the attendees_group # is not connected to them, because a non-group object, event1, is in between. @@ -22,39 +22,45 @@ # rather than indirect graph connections to achieve the neccessary read performance. # concern :StructureableConnectedGroups do - + + def delete_cache + super + @ancestor_groups = nil + @descendant_groups = nil + end + def connected_ancestor_groups Group.find connected_ancestor_group_ids end - + def connected_ancestor_group_ids cached { select_connected_groups(parent_groups).collect { |parent_group| [parent_group.id] + parent_group.connected_ancestor_group_ids }.flatten.uniq } end - + def connected_descendant_groups Group.find connected_descendant_group_ids end - + def connected_descendant_group_ids cached { select_connected_groups(child_groups).collect { |child_group| [child_group.id] + child_group.connected_descendant_group_ids }.flatten.uniq } end - + def ancestor_groups(reload = false) @ancestor_groups = nil if reload @ancestor_groups ||= connected_ancestor_groups end - + def descendant_groups(reload = false) @descendant_groups = nil if reload @descendant_groups ||= connected_descendant_groups end - + private - + def select_connected_groups(groups) groups.select do |group| not group.has_flag? :officers_parent end end - + end \ No newline at end of file diff --git a/app/models/dag_link.rb b/app/models/dag_link.rb index 44b4fd83f..b0f331672 100644 --- a/app/models/dag_link.rb +++ b/app/models/dag_link.rb @@ -13,13 +13,13 @@ class DagLink < ActiveRecord::Base attr_accessible :ancestor_id, :ancestor_type, :count, :descendant_id, :descendant_type, :direct if defined? attr_accessible has_many_flags - + belongs_to :ancestor, :polymorphic => true belongs_to :descendant, :polymorphic => true - + validates :ancestor_type, :presence => true validates :descendant_type, :presence => true - + scope :with_ancestor, lambda { |ancestor| where(:ancestor_id => ancestor.id, :ancestor_type => ancestor.class.to_s) } scope :with_descendant, lambda { |descendant| where(:descendant_id => descendant.id, :descendant_type => descendant.class.to_s) } @@ -28,28 +28,28 @@ class DagLink < ActiveRecord::Base scope :ancestor_nodes, lambda { joins(:ancestor) } scope :descendant_nodes, lambda { joins(:descendant) } - + validates :ancestor, :presence => true validates :descendant, :presence => true - + before_validation :fill_defaults, :on => :update before_validation :fill_defaults, :on => :create - + # We have to workaround a bug in Rails 3 here. But, since Rails 3 is no longer fully supported, # this is not going to be fixed. - # + # # https://github.com/rails/rails/issues/7618 # # With our workaround, the `delete_cache` method is called on the `DagLink` when # `group.members.destroy(user)` is called. - # + # # See: app/models/active_record_associations_patches.rb # after_save { self.delay.delete_cache } before_destroy :delete_cache - + include DagLinkValidityRange - + def fill_cache valid_from end @@ -61,13 +61,13 @@ def delete_cache ancestor.connected_ancestor_groups.each { |g| g.delete_cached :connected_descendant_group_ids } descendant.connected_descendant_groups.each { |g| g.delete_cached :connected_ancestor_group_ids } if descendant.kind_of? Group end - - # These are defaults that are needed while migrating from the + + # These are defaults that are needed while migrating from the # acts_as_dag gem to the new mechanism. # def fill_defaults self.direct = true if self.direct.nil? self.count = 0 if self.count.nil? end - + end diff --git a/app/models/structureable.rb b/app/models/structureable.rb index a4a1d96c3..7c3ee7880 100644 --- a/app/models/structureable.rb +++ b/app/models/structureable.rb @@ -3,18 +3,18 @@ # This module provides the ActiveRecord::Base extension `is_structureable`, which characterizes # a model as part of the global dag_link structure in this project. All structureable objects # are nodes of this dag link. -# -# Examples: +# +# Examples: # @page1.parent_pages << @page2 # @page1.parents # => [ @page2, ... ] -# +# # @group.child_users << @user # @group.children # => [ @user, ... ] # @user.parents # => [ @group, ... ] -# -# For all methods that are provided, please consult the documentations of the +# +# For all methods that are provided, please consult the documentations of the # `acts-as-dag` gem and of the `acts_as_paranoid_dag` gem. -# +# # This module is included in ActiveRecord::Base via an initializer at # config/initializers/active_record_structureable_extension.rb # @@ -22,18 +22,18 @@ module Structureable # options: ancestor_class_names, descendant_class_names - # This method is used to declare a model as structureable, i.e. part of the global - # dag link structure. - # + # This method is used to declare a model as structureable, i.e. part of the global + # dag link structure. + # # Options: # ancestor_class_names # descendant_class_names # link_class_name (default: 'DagLink') - # + # # For detailed information on the options, please see the documentation of the # `acts-as-dag` gem, since these options are forwarded to the has_dag_links method. # http://rubydoc.info/github/resgraph/acts-as-dag/Dag#has_dag_links-instance_method - # + # # Example: # class Group < ActiveRecord::Base # is_structureable ancestor_class_names: %w(Group), descendant_class_names: %w(Group User) @@ -41,9 +41,9 @@ module Structureable # class User < ActiveRecord::Base # is_structureable ancestor_class_names: %w(Group) # end - # + # def is_structureable( options = {} ) - + # default options conf = { :link_class_name => 'DagLink' @@ -53,7 +53,7 @@ def is_structureable( options = {} ) # the model is part of the dag link structure. see gem `acts-as-dag` has_dag_links conf - + before_destroy :destroy_links # see Flagable model. @@ -63,62 +63,36 @@ def is_structureable( options = {} ) # This mixin loads the necessary methods to interact with them. # include StructureableMixins::HasSpecialGroups - + # To use `prepend` here allows to call `super` in the methods # defined in the module `StructureableInstanceMethods`. # prepend StructureableInstanceMethods - + # Use the connected-groups mechanism. # include StructureableConnectedGroups include StructureableConnectedLeafGroups include StructureableConnectedPages - + # Methods to manage the graph-related cache. # include StructureableGraphCache end module StructureableInstanceMethods - + # Include Rules, e.g. let this object have admins. - # + # include StructureableMixins::Roles # When a dag node is destroyed, also destroy the corresponding dag links. - # Otherwise, there would remain ghost dag links in the database that would - # corrupt the integrity of the database. - # - # If the database gets ever messed up like this, delete the concerning - # *direct* dag links by hand and then run this rake task to re-create - # the indirect dag links: - # - # rake reconstruct_indirect_dag_links:all - # + # Otherwise, there would remain ghost dag links in the database. + # def destroy_dag_links - - # destory only child and parent links, since the indirect links - # are destroyed automatically by the DagLink model then. - links = self.links_as_parent + self.links_as_child - - for link in links do - - if link.destroyable? - link.destroy - else - - # In facty, all these links should be destroyable. If this error should - # be raised, something really went wrong. Please send in a bug report then - # at http://github.com/fiedl/your_platform. - raise "Could not destroy dag links of the structureable object that should be deleted." + - " Please send in a bug report at http://github.com/fiedl/your_platform." - return false - end - - end + (links_as_parent + links_as_child).each(&:destroy) end - + # This somehow identifies which are the ancestors of this structureable. # For example, this is used in the breadcrumb helper. # @@ -132,7 +106,7 @@ def children_cache_key def destroy_links self.destroy_dag_links end - + # Move the node to another parent. # def move_to(parent_node) @@ -144,7 +118,7 @@ def move_to(parent_node) self.update_attribute :updated_at, old_updated_at end end - + # Adding child objects. # def <<(object) @@ -186,7 +160,7 @@ def <<(object) raise e if not File.basename($0) == 'rake' end end - - + + end end diff --git a/spec/models/graph_performance_spec.rb b/spec/models/graph_performance_spec.rb new file mode 100644 index 000000000..fd5f4f0bb --- /dev/null +++ b/spec/models/graph_performance_spec.rb @@ -0,0 +1,263 @@ +# This test creates and moves some groups in order to +# determine the graph performance. +# +# This is the same test as: +# https://github.com/fiedl/neo4j_ancestry_vs_acts_as_dag/blob/master/spec/performance_spec.rb +# https://github.com/fiedl/neo4j_gem_test/blob/master/spec/performance_spec.rb +# +require 'spec_helper' +require 'table-formatter' + +if ENV['CI'] != 'travis' + + class TestGraph + def initialize(params) + @number_of_groups = params[:number_of_groups] + @number_of_users = params[:number_of_users] + end + + def create_groups + @groups = (1..@number_of_groups).map { |n| Group.create(name: "Group #{n}") } + end + def groups + @groups + end + + def create_parent_group + @parent_group = Group.create name: "Parent Group" + end + def parent_group + @parent_group + end + + def create_ancestor_group + @ancestor_group = Group.create name: "Ancestor Group" + end + def ancestor_group + @ancestor_group + end + + def add_users_to_groups + groups.each do |group| + (1..@number_of_users).each { |n| group.child_users << FactoryGirl.create(:user) } + end + end + + def move_groups_into_parent_group + groups.each do |group| + parent_group.child_groups << group + end + end + def move_parent_group_into_ancestor_group + ancestor_group.child_groups << parent_group + end + def remove_the_link_of_the_ancestor_group + ancestor_group.child_groups.destroy(parent_group) + end + + + def number_of_users_of_the_last_group + groups.last.descendant_users.count + end + def number_of_parent_group_child_groups + parent_group.child_groups.count + end + def number_of_ancestor_group_descendant_groups + ancestor_group.descendant_groups.count + end + def number_of_ancestor_group_members + ancestor_group.descendant_users.count + end + def number_of_parent_group_ancestor_groups + parent_group.ancestor_groups.count + end + end + + class Neo4jTestGraph < TestGraph + def number_of_ancestor_group_descendant_groups + ancestor_group.neo_node.query_as(:self).match("self-[*]->(g:Group)").pluck(:g).count + end + def number_of_parent_group_ancestor_groups + parent_group.neo_node.query_as(:self).match("self<-[*]-(n)").pluck(:n).count + end + end + + class ConnectedGroupsTestGraph < TestGraph + def number_of_users_of_the_last_group + groups.last.members.count + end + def number_of_ancestor_group_members + ancestor_group.members.count + end + end + + describe "graph performance: " do + + $number_of_groups = 100 + $number_of_users = 10 + + before :each do + clear_db + end + + let(:graph) { + params = {number_of_groups: $number_of_groups, number_of_users: $number_of_users} + if defined?(Neo4j) + Neo4jTestGraph.new(params) + elsif defined?(StructureableConnectedGroups) + ConnectedGroupsTestGraph.new(params) + else + TestGraph.new(params) + end + } + + specify "creating #{$number_of_groups} groups" do + benchmark { graph.create_groups } + graph.groups.count.should == $number_of_groups + end + + specify "adding #{$number_of_users} users to each of the #{$number_of_groups} groups" do + graph.create_groups + benchmark { graph.add_users_to_groups } + graph.number_of_users_of_the_last_group.should == $number_of_users + end + + specify "moving #{$number_of_groups} groups into a parent group" do + graph.create_groups + graph.create_parent_group + benchmark { graph.move_groups_into_parent_group } + graph.number_of_parent_group_child_groups.should == $number_of_groups + end + + specify "moving the group structure into an ancestor group" do + graph.create_groups + graph.create_parent_group + graph.move_groups_into_parent_group + graph.create_ancestor_group + benchmark { graph.move_parent_group_into_ancestor_group } + graph.number_of_ancestor_group_descendant_groups.should == $number_of_groups + 1 + end + + specify "moving the groups with users into an ancestor group" do + graph.create_groups + graph.add_users_to_groups + graph.create_parent_group + graph.move_groups_into_parent_group + graph.create_ancestor_group + benchmark { graph.move_parent_group_into_ancestor_group } + graph.number_of_ancestor_group_descendant_groups.should == $number_of_groups + 1 + graph.number_of_ancestor_group_members.should == $number_of_groups * $number_of_users + end + + specify "removing the link to the ancestor group" do + graph.create_groups + graph.add_users_to_groups + graph.create_parent_group + graph.move_groups_into_parent_group + graph.create_ancestor_group + graph.move_parent_group_into_ancestor_group + benchmark { graph.remove_the_link_of_the_ancestor_group } + graph.number_of_ancestor_group_descendant_groups.should == 0 + end + + specify "destroying the ancestor group" do + graph.create_groups + graph.add_users_to_groups + graph.create_parent_group + graph.move_groups_into_parent_group + graph.create_ancestor_group + graph.move_parent_group_into_ancestor_group + benchmark { graph.ancestor_group.destroy } + graph.number_of_parent_group_ancestor_groups.should == 0 + end + + + + + describe "with child users" do + describe "with parent group" do + describe "with ancestor group" do + + + + specify "finding all descendants" do + if defined? Neo4j and parent_group.respond_to? :neo_node + benchmark do + ancestor_group.neo_node.query_as(:self).match("self-[*]->(n)").pluck(:n).collect { |n| n.to_active_record } + end + else + benchmark do + ancestor_group.descendants.to_a + end + end + + if defined? Neo4j and ancestor_group.respond_to? :neo_node + ancestor_group.neo_node.query_as(:self).match("self-[*]->(n)").pluck(:n).count.should > $number_of_groups + end + ancestor_group.descendants.count.should > $number_of_groups + end + + specify "finding all descendant users" do + if defined? Neo4j + benchmark do + User.find(ancestor_group.neo_node.query_as(:self).match("self-[*]->(u:User)").pluck('u.active_record_id')) + end + else + benchmark do + ancestor_group.descendant_users.to_a + end + end + + if defined? Neo4j + User.find(ancestor_group.neo_node.query_as(:self).match("self-[*]->(u:User)").pluck('u.active_record_id')).count.should > $number_of_groups + User.find(ancestor_group.neo_node.query_as(:self).match("self-[*]->(u:User)").pluck('u.active_record_id')).first.should be_kind_of User + end + ancestor_group.descendant_users.count.should > $number_of_groups + ancestor_group.descendant_users.first.should be_kind_of User + end + end + end + end + + after(:all) do + print_results + end + + $results = [] + def benchmark + duration_in_seconds = Benchmark.realtime { + yield + }.round(4) + + description = RSpec.current_example.metadata[:description] if RSpec.respond_to? :current_example # rspec 3 + description ||= example.description # rspec 2 + duration = "#{duration_in_seconds} seconds" + + $results << [description, "#{duration_in_seconds.to_s} s"] + print "#{description}: #{duration}.\n".blue + end + + def print_results + print "\n\n## Results for #{ENV['BACKEND']}\n\n".blue.bold + + print "$number_of_groups = #{$number_of_groups}\n".blue + print "$number_of_users = #{$number_of_users}\n\n".blue + + print results_table.blue.bold + end + def results_table + t = TableFormatter.new + t.source = $results + t.labels = ['Description', 'Duration'] + t.display.to_s + end + + def clear_db + if defined? Neo4j + # clear_model_memory_caches + Neo4j::Session.current._query('MATCH (n) OPTIONAL MATCH (n)-[r]-() DELETE n,r') + end + end + + end +end \ No newline at end of file diff --git a/your_platform.gemspec b/your_platform.gemspec index 8fb08bbe6..ec410ecbb 100644 --- a/your_platform.gemspec +++ b/your_platform.gemspec @@ -21,7 +21,7 @@ Gem::Specification.new do |s| s.files = Dir["{app,config,db,lib,vendor}/**/*"] + ["Rakefile", "README.md"] s.test_files = Dir["spec/**/*"] - # Dependencies + # Dependencies # -------------------------------------------------------------------------------- # Rails and Rails Additions @@ -34,8 +34,8 @@ Gem::Specification.new do |s| s.add_dependency "bundler", ">= 1.9.4" s.add_dependency 'web-console', '>= 2.1.3' - - # JavaScript + + # JavaScript s.add_dependency "jquery-rails", '>= 3.1.3', '<= 4.0.4' # Fix version due to datatables issue (http://stackoverflow.com/a/31150030/2066546) s.add_dependency "jquery-ui-rails", '~> 4.2.0' # MIT, GPL2 s.add_dependency "autosize-rails" # autosize textbox @@ -49,15 +49,15 @@ Gem::Specification.new do |s| # Retry transactions: Rescue from deadlocks. s.add_dependency 'transaction_retry' s.add_dependency 'acts_as_tree' # MIT License - + # Caching s.add_dependency 'redis-rails' - + # Workers s.add_dependency 'foreman' s.add_dependency 'sidekiq', '>= 3.4.2' s.add_dependency 'sidekiq-limit_fetch' - + # Authentification s.add_dependency 'devise', '>= 2.2.5' # MIT License @@ -67,7 +67,7 @@ Gem::Specification.new do |s| s.add_dependency 'cancan' # MIT License # To use ActiveModel has_secure_password (password encryption) - s.add_dependency 'bcrypt', '>= 3.0.1' # MIT License + s.add_dependency 'bcrypt', '>= 3.0.1' # MIT License # Settings s.add_dependency 'rails-settings-cached' @@ -78,7 +78,7 @@ Gem::Specification.new do |s| s.add_dependency 'redcarpet', '>= 3.3.2' # for Markdown # MIT License s.add_dependency 'gemoji', '>= 2.1.0' s.add_dependency 'auto_html', '>= 1.6.4' - + # Layout: Twitter Bootstrap s.add_dependency 'font-awesome-rails', '~> 4.3.0' # fix bootstrap to 3.3.3 due to icon issue: @@ -87,7 +87,7 @@ Gem::Specification.new do |s| # In Place Editing s.add_dependency 'best_in_place', '>= 2.1.0' # MIT License - + # Geo Coding s.add_dependency 'geocoder' # MIT License s.add_dependency 'gmaps4rails', '2.0.2' # CURRENTLY ONLY THE FORK WORKS FOR US # MIT License @@ -110,52 +110,55 @@ Gem::Specification.new do |s| # Hide slim breadcrumb elements until user hovers the separator s.add_dependency 'slim_breadcrumb', '>= 0.0.3' # MIT License - + # Workflow Kit s.add_dependency 'workflow_kit', '~> 0.0.7' # MIT License # View Helpers - s.add_dependency 'phony' + s.add_dependency 'phony' s.add_dependency 'will_paginate', '> 3.0' s.add_dependency 'jquery-datatables-rails', '~> 3.1.1' - + # JavaScript s.add_dependency 'turbolinks', '>= 2.5.3' s.add_dependency 'jquery-turbolinks' s.add_dependency 'turboboost' - + # Client-Side Validations s.add_dependency 'judge' - + # Metrics s.add_dependency 'fnordmetric' # MIT License s.add_dependency 'rack-mini-profiler', '>= 0.9.0.pre' # MIT License - + # Activity Feed s.add_dependency 'public_activity', '~> 1.4.1' # MIT License - + # XLS Export s.add_dependency 'to_xls' - + # PDF Export s.add_dependency 'prawn' - + # ICS Export (iCal) s.add_dependency 'icalendar' - + # Gamification s.add_dependency 'merit' - + # Dummy Data Generation s.add_dependency 'faker' - + + # Console + s.add_dependency "table-formatter" + # Fixes # https://github.com/eventmachine/eventmachine/issues/509 s.add_dependency 'eventmachine', '>= 1.0.7' # https://github.com/lautis/uglifier/pull/86 - s.add_dependency 'uglifier', '>= 2.7.2' + s.add_dependency 'uglifier', '>= 2.7.2' - # Development Dependencies + # Development Dependencies # -------------------------------------------------------------------------------- s.add_development_dependency "rspec-rails", "2.10.0" From bfabfbed8107d7e2806a755bf9c7b55071b1dd01 Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Tue, 12 Apr 2016 20:22:19 +0200 Subject: [PATCH 41/42] working on graph performance specs. 2015-eil4tohK --- spec/models/graph_performance_spec.rb | 111 ++++++++++++++------------ 1 file changed, 62 insertions(+), 49 deletions(-) diff --git a/spec/models/graph_performance_spec.rb b/spec/models/graph_performance_spec.rb index fd5f4f0bb..53baa96d3 100644 --- a/spec/models/graph_performance_spec.rb +++ b/spec/models/graph_performance_spec.rb @@ -71,6 +71,20 @@ def number_of_ancestor_group_members def number_of_parent_group_ancestor_groups parent_group.ancestor_groups.count end + + def find_ancestor_group_descendants + ancestor_group.descendants.to_a + end + def find_ancestor_group_members + ancestor_group.members.to_a + end + def find_connected_descendant_groups + ancestor_group.connected_descendant_groups.to_a + end + + def clear_graph + DagLink.delete_all + end end class Neo4jTestGraph < TestGraph @@ -80,6 +94,18 @@ def number_of_ancestor_group_descendant_groups def number_of_parent_group_ancestor_groups parent_group.neo_node.query_as(:self).match("self<-[*]-(n)").pluck(:n).count end + + def find_ancestor_group_descendants + ancestor_group.neo_node.query_as(:self).match("self-[*]->(n)").pluck(:n).collect { |n| n.to_active_record } + end + def find_ancestor_group_members + User.find(ancestor_group.neo_node.query_as(:self).match("self-[*]->(u:User)").pluck('u.active_record_id')) + end + + def clear_db + # clear_model_memory_caches + Neo4j::Session.current._query('MATCH (n) OPTIONAL MATCH (n)-[r]-() DELETE n,r') + end end class ConnectedGroupsTestGraph < TestGraph @@ -171,52 +197,42 @@ def number_of_ancestor_group_members graph.number_of_parent_group_ancestor_groups.should == 0 end + specify "finding all #{$number_of_groups * $number_of_users} members" do + graph.create_groups + graph.add_users_to_groups + graph.create_parent_group + graph.move_groups_into_parent_group + graph.create_ancestor_group + graph.move_parent_group_into_ancestor_group + benchmark { graph.find_ancestor_group_members } + benchmark("second run: finding all #{$number_of_groups * $number_of_users} members") { graph.find_ancestor_group_members } + graph.find_ancestor_group_members.count.should == $number_of_groups * $number_of_users + graph.find_ancestor_group_members.first.should be_kind_of User + end + specify "finding all connected descendant groups" do + graph.create_groups + graph.add_users_to_groups + graph.create_parent_group + graph.move_groups_into_parent_group + graph.create_ancestor_group + graph.move_parent_group_into_ancestor_group + benchmark { graph.find_connected_descendant_groups } + benchmark("second run: all connected descendant groups") { graph.find_connected_descendant_groups } + graph.find_connected_descendant_groups.count.should == $number_of_groups + 1 + end - - describe "with child users" do - describe "with parent group" do - describe "with ancestor group" do - - - - specify "finding all descendants" do - if defined? Neo4j and parent_group.respond_to? :neo_node - benchmark do - ancestor_group.neo_node.query_as(:self).match("self-[*]->(n)").pluck(:n).collect { |n| n.to_active_record } - end - else - benchmark do - ancestor_group.descendants.to_a - end - end - - if defined? Neo4j and ancestor_group.respond_to? :neo_node - ancestor_group.neo_node.query_as(:self).match("self-[*]->(n)").pluck(:n).count.should > $number_of_groups - end - ancestor_group.descendants.count.should > $number_of_groups - end - - specify "finding all descendant users" do - if defined? Neo4j - benchmark do - User.find(ancestor_group.neo_node.query_as(:self).match("self-[*]->(u:User)").pluck('u.active_record_id')) - end - else - benchmark do - ancestor_group.descendant_users.to_a - end - end - - if defined? Neo4j - User.find(ancestor_group.neo_node.query_as(:self).match("self-[*]->(u:User)").pluck('u.active_record_id')).count.should > $number_of_groups - User.find(ancestor_group.neo_node.query_as(:self).match("self-[*]->(u:User)").pluck('u.active_record_id')).first.should be_kind_of User - end - ancestor_group.descendant_users.count.should > $number_of_groups - ancestor_group.descendant_users.first.should be_kind_of User - end - end + specify "creating 10 events for ancestor group" do + graph.create_groups + graph.add_users_to_groups + graph.create_parent_group + graph.move_groups_into_parent_group + graph.create_ancestor_group + graph.move_parent_group_into_ancestor_group + benchmark do + 10.times { graph.ancestor_group.child_events.create } end + graph.ancestor_group.events.count.should == 10 end after(:all) do @@ -224,12 +240,12 @@ def number_of_ancestor_group_members end $results = [] - def benchmark + def benchmark(description = nil) duration_in_seconds = Benchmark.realtime { yield }.round(4) - description = RSpec.current_example.metadata[:description] if RSpec.respond_to? :current_example # rspec 3 + description ||= RSpec.current_example.metadata[:description] if RSpec.respond_to? :current_example # rspec 3 description ||= example.description # rspec 2 duration = "#{duration_in_seconds} seconds" @@ -253,10 +269,7 @@ def results_table end def clear_db - if defined? Neo4j - # clear_model_memory_caches - Neo4j::Session.current._query('MATCH (n) OPTIONAL MATCH (n)-[r]-() DELETE n,r') - end + graph.clear_graph end end From 6e3f11517e5bd43277e0a28486d4a93c0c2fb4d4 Mon Sep 17 00:00:00 2001 From: Sebastian Fiedlschuster Date: Tue, 12 Apr 2016 23:52:06 +0200 Subject: [PATCH 42/42] working on connected groups to get lots of model specs green 2015-eil4tohK --- app/models/concerns/membership_persistence.rb | 30 ++--- .../structureable_connected_descendants.rb | 9 ++ .../structureable_connected_groups.rb | 4 +- app/models/concerns/user_roles.rb | 44 +++---- app/models/dag_link.rb | 2 +- app/models/event.rb | 65 ++++----- app/models/group.rb | 50 +++---- app/models/group_collection.rb | 28 ++-- app/models/group_mixins/guests.rb | 14 +- app/models/group_mixins/roles.rb | 12 +- app/models/list_export.rb | 60 +++++---- app/models/list_exports/base.rb | 30 +++-- app/models/role.rb | 54 ++++---- app/models/structureable.rb | 1 + app/models/structureable_mixins/roles.rb | 56 ++++---- spec/models/ability_spec.rb | 81 ++++++------ .../ability_to_use_mailing_lists_spec.rb | 50 +++---- spec/models/concerns/user_roles_spec.rb | 124 +++++++++--------- spec/models/dag_link_spec.rb | 48 ------- spec/models/event_spec.rb | 52 ++++---- spec/models/graph_performance_spec.rb | 4 +- spec/models/group_mixins/guests_spec.rb | 8 +- spec/models/group_spec.rb | 30 ++--- 23 files changed, 408 insertions(+), 448 deletions(-) create mode 100644 app/models/concerns/structureable_connected_descendants.rb delete mode 100644 spec/models/dag_link_spec.rb diff --git a/app/models/concerns/membership_persistence.rb b/app/models/concerns/membership_persistence.rb index 5e9894e8d..8ab5c56e6 100644 --- a/app/models/concerns/membership_persistence.rb +++ b/app/models/concerns/membership_persistence.rb @@ -3,7 +3,7 @@ # Direct memberships are stored as DagLinks in the database. # This is, because we've used the acts_as_dag gem earlier: # https://github.com/resgraph/acts-as-dag - # + # # In contrast to the gem, we do not store indirect links # in the database anymore, since this makes write operations # too expensive for large graphs. @@ -12,7 +12,7 @@ def dag_link @dag_link ||= DagLink.where(ancestor_type: 'Group', descendant_type: 'User', direct: true, ancestor_id: group.id, descendant_id: user.id).first end - + def id dag_link.try(:id) end @@ -20,28 +20,28 @@ def id def persisted? dag_link.try(:persisted?) || false end - + def save write_attributes_to_dag_link dag_link.save end - + def save! raise 'Cannot save! Indirect memberships are non-persistent objects.' unless direct? write_attributes_to_dag_link dag_link.changed? ? dag_link.save! : true end - + def update_attributes!(attrs = {}) set_attributes(attrs) save! end - + def update_attributes(attrs = {}) set_attributes(attrs) save if direct? end - + def reload @dag_link = nil @valid_from = dag_link.valid_from @@ -50,21 +50,21 @@ def reload end delegate :destroyed?, :new_record?, to: :dag_link - + def destroyable? - direct? && dag_link.destroyable? + direct? end - + def destroy (destroyable? && dag_link.try(:destroy)) || raise("could not destroy membership #{id}.") end - + def _read_attribute(key) send(key) if key.in? [:valid_from, :valid_to] end private - + def write_attributes_to_dag_link dag_link.valid_from = @valid_from dag_link.valid_to = @valid_to @@ -77,8 +77,8 @@ def set_attributes(attrs) send("#{key}=", value) end end - - + + class_methods do def base_class Membership @@ -87,7 +87,7 @@ def base_class def primary_key :id end - + def build(params) group_id = params[:group_id] || params[:group].try(:id) user_id = params[:user_id] || params[:user].try(:id) diff --git a/app/models/concerns/structureable_connected_descendants.rb b/app/models/concerns/structureable_connected_descendants.rb new file mode 100644 index 000000000..32c9d92f1 --- /dev/null +++ b/app/models/concerns/structureable_connected_descendants.rb @@ -0,0 +1,9 @@ +concern :StructureableConnectedDescendants do + + def connected_descendants + connected_descendant_groups.collect do |g| + [g] + g.members.to_a + g.connected_descendant_pages + g.child_events + end.flatten.uniq + end + +end \ No newline at end of file diff --git a/app/models/concerns/structureable_connected_groups.rb b/app/models/concerns/structureable_connected_groups.rb index 10ebbc4ef..442aa7d11 100644 --- a/app/models/concerns/structureable_connected_groups.rb +++ b/app/models/concerns/structureable_connected_groups.rb @@ -30,7 +30,7 @@ def delete_cache end def connected_ancestor_groups - Group.find connected_ancestor_group_ids + Group.where(id: connected_ancestor_group_ids) end def connected_ancestor_group_ids @@ -38,7 +38,7 @@ def connected_ancestor_group_ids end def connected_descendant_groups - Group.find connected_descendant_group_ids + Group.where(id: connected_descendant_group_ids) end def connected_descendant_group_ids diff --git a/app/models/concerns/user_roles.rb b/app/models/concerns/user_roles.rb index 34d41382c..8fa73cbbd 100644 --- a/app/models/concerns/user_roles.rb +++ b/app/models/concerns/user_roles.rb @@ -1,5 +1,5 @@ concern :UserRoles do - + # Roles and Rights # ========================================================================================== @@ -14,16 +14,16 @@ def role_for( structureable ) return :admin if self.admin_of? structureable return :member if self.member_of? structureable end - + # Member Status # ------------------------------------------------------------------------------------------ - - # This method is a dirty hack to preserve the obsolete role model mechanism, - # which is currently not in use, since the abilities are defined directly in the + + # This method is a dirty hack to preserve the obsolete role model mechanism, + # which is currently not in use, since the abilities are defined directly in the # Ability class. # # Options: - # + # # with_invalid, also_in_the_past : true/false # # TODO: refactor it together with the role model mechanism. @@ -43,7 +43,7 @@ def member_of?( object, options = {} ) # Officer Status # ------------------------------------------------------------------------------------------ - + def officer_groups cached { self.groups.select { |g| g.type == "OfficerGroup" } } end @@ -78,7 +78,7 @@ def directly_administrated_objects( role = :admin ) if admin_groups.count > 0 objects = admin_groups.collect do |admin_group| admin_group.administrated_object - end + end - [nil] else [] end @@ -90,7 +90,7 @@ def administrated_objects( role = :admin ) objects = directly_administrated_objects( role ) if objects objects += objects.collect do |directly_administrated_object| - directly_administrated_object.descendants + directly_administrated_object.connected_descendants end.flatten objects else @@ -130,9 +130,9 @@ def former_member_of_corporation?( corporation ) # Developer Status # ========================================================================================== - # This method returns whether the user is a developer. This is needed, for example, - # to determine if some features are presented to the current_user. - # + # This method returns whether the user is a developer. This is needed, for example, + # to determine if some features are presented to the current_user. + # def developer? cached { self.developer } end @@ -146,10 +146,10 @@ def developer=( mark_as_developer ) Group.developers.unassign_user self end end - + # Beta Tester Status # ========================================================================================== - + def beta_tester? @beta_tester ||= self.beta_tester end @@ -163,7 +163,7 @@ def beta_tester=(mark_as_beta_tester) Group.find_or_create_by_flag(:beta_testers).child_users.destroy(self) end end - + # Global Admin Switch # ========================================================================================== @@ -181,19 +181,19 @@ def global_admin=(new_setting) UserGroupMembership.find_by_user_and_group(self, Group.everyone.admins_parent).try(:destroy) end end - + # Officers # ========================================================================================== - + def officer_of_anything? self.groups.detect { |g| g.type == 'OfficerGroup' } || false end - + def officer_of?(obj) obj.officer_groups.collect { |g| g.members.to_a }.flatten.include? self end - + def officer_or_subgroup_officer_of?(obj) obj.officers_groups_of_self_and_descendant_groups.collect { |g| g.members.to_a }.flatten.include? self end @@ -207,7 +207,7 @@ def global_officer? end def is_global_officer? - cached { global_admin? || ancestor_groups.flagged(:global_officer).exists? } + cached { global_admin? || groups.flagged(:global_officer).any? } end def administrated_user_ids @@ -221,6 +221,6 @@ def administrates_user?(id) end return false end - - + + end \ No newline at end of file diff --git a/app/models/dag_link.rb b/app/models/dag_link.rb index b0f331672..b7a19140b 100644 --- a/app/models/dag_link.rb +++ b/app/models/dag_link.rb @@ -58,7 +58,7 @@ def delete_cache super ancestor.try(:delete_cache) descendant.try(:delete_cache) - ancestor.connected_ancestor_groups.each { |g| g.delete_cached :connected_descendant_group_ids } + ancestor.connected_ancestor_groups.each { |g| g.delete_cached :connected_descendant_group_ids } if ancestor.respond_to? :parent_groups descendant.connected_descendant_groups.each { |g| g.delete_cached :connected_ancestor_group_ids } if descendant.kind_of? Group end diff --git a/app/models/event.rb b/app/models/event.rb index 7e1bee65f..c1eb6f59f 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -6,7 +6,7 @@ class Event < ActiveRecord::Base has_many :attachments, as: :parent, dependent: :destroy - + # General Properties # ========================================================================================== @@ -14,7 +14,7 @@ class Event < ActiveRecord::Base def title name end - + def to_param "#{id} #{name} #{start_at.year}-#{start_at.month}-#{start_at.day}".parameterize end @@ -39,10 +39,10 @@ def group=( group ) def groups self.parent_groups end - + # Times # ========================================================================================== - + def localized_start_at I18n.localize start_at.to_time if start_at.present? end @@ -50,7 +50,7 @@ def localized_start_at=(string) attribute_will_change! :start_at self.start_at = string.present? ? LocalizedDateTimeParser.parse(string, Time).to_time : nil end - + def localized_end_at I18n.localize end_at.to_time if end_at.present? end @@ -58,12 +58,12 @@ def localized_end_at=(string) attribute_will_change! :end_at self.end_at = string.present? ? LocalizedDateTimeParser.parse(string, Time).to_time : nil end - + # Contact People and Attendees # ========================================================================================== - + def find_contact_people_group find_special_group :contact_people end @@ -76,7 +76,7 @@ def contact_people_group def contact_people contact_people_group.members end - + def find_attendees_group find_special_group :attendees end @@ -89,13 +89,13 @@ def attendees_group def attendees attendees_group.members end - + def destroy find_attendees_group.try(:destroy) find_contact_people_group.try(:destroy) super end - + # Scopes # ========================================================================================== @@ -118,39 +118,26 @@ def destroy # Date.today.to_datetime is 0h. # scope :upcoming, lambda { where("(start_at > ? AND end_at IS NULL) OR (end_at IS NOT NULL AND end_at > ?)", Date.today.to_datetime, Date.today.to_datetime) } - + def upcoming? Event.upcoming.pluck(:id).include? self.id end - - scope :direct, lambda { includes( :links_as_descendant ).where( :dag_links => { :direct => true } ) } - # Finder Methods # ========================================================================================== - def self.find_all_by_group( group ) - ancestor_id = group.id if group - self.includes( :links_as_descendant ) - .where( :dag_links => { - :ancestor_type => "Group", :ancestor_id => ancestor_id - } ) - .order('start_at') + def self.find_all_by_group(group) + self.where(id: ([group] + group.connected_descendant_groups).map(&:child_event_ids).flatten).order(:start_at) end - def self.find_all_by_groups( groups ) - group_ids = groups.collect { |g| g.id } - self.includes( :links_as_descendant ) - .where( :dag_links => { - :ancestor_type => "Group", :ancestor_id => group_ids - } ) - .order('start_at') + def self.find_all_by_groups(groups) + self.where(id: groups.collect { |g| [g] + g.connected_descendant_groups }.flatten.map(&:child_event_ids).flatten).order(:start_at) end - + def self.find_all_by_user(user) self.find_all_by_groups(user.groups).direct end - + # Calendar Export # ========================================================================================== @@ -172,7 +159,7 @@ def to_icalendar_event e.last_modified = Icalendar::Values::DateTime.new(self.updated_at) return e end - + def to_icalendar cal = Icalendar::Calendar.new cal.add_event self.to_icalendar_event @@ -183,18 +170,18 @@ def to_icalendar def to_ics self.to_icalendar.to_ical end - + def to_ical self.to_ics end - + # Example: # Group.find(12).events.to_ics # def self.to_ics self.to_icalendar.to_ical end - + def self.to_icalendar cal = Icalendar::Calendar.new self.all.each do |event| @@ -203,18 +190,18 @@ def self.to_icalendar cal.publish return cal end - + def self.to_ical self.to_ics end - + # Existance # ========================================================================================== - + # For some strange reason, the callbacks of the creation of an event appear to # prevent the event form being found in the database after its creation. - # + # # In the EventsController and in specs, we need to make sure that the event exists # before continuing. Otherwise `ActiveRecord::RecordNotFound` is raised in the controller # or when redirecting to the event. @@ -231,5 +218,5 @@ def wait_for_me_to_exist retry end end - + end diff --git a/app/models/group.rb b/app/models/group.rb index 501ead6e8..13a6a0ac6 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -3,15 +3,15 @@ # This class represents a user group. Besides users, groups may have sub-groups as children. # One group may have several parent-groups. Therefore, the relations between groups, users, # etc. is stored using the DAG model, which is implemented by the `is_structureable` method. -# +# class Group < ActiveRecord::Base - + if defined? attr_accessible attr_accessible( :name, # just the name of the group; example: 'Corporation A' :body, # a description text displayed on the groups pages top - :token, # (optional) a short-name, abbreviation of the group's name, in + :token, # (optional) a short-name, abbreviation of the group's name, in # a global context; example: 'A' - :internal_token, # (optional) an internal abbreviation, i.e. used by the + :internal_token, # (optional) an internal abbreviation, i.e. used by the # members of the group; example: 'AC' :extensive_name, # (optional) a long version of the group's name; # example: 'The Corporation of A' @@ -19,21 +19,21 @@ class Group < ActiveRecord::Base # titles of the child users of the group. ) end - + include ActiveModel::ForbiddenAttributesProtection # TODO: Move into initializer - is_structureable(ancestor_class_names: %w(Group Page Event), + is_structureable(ancestor_class_names: %w(Group Page Event), descendant_class_names: %w(Group User Page Workflow Event Project)) is_navable has_profile_fields has_many :posts - + default_scope { includes(:flags) } include GroupMemberships include GroupMemberAssignment - include GroupMixins::Everyone + include GroupMixins::Everyone include GroupMixins::Corporations include GroupMixins::Roles include GroupMixins::Guests @@ -52,14 +52,14 @@ def delete_cache super ancestor_groups(true).each { |g| g.delete_cached(:leaf_groups); g.delete_cached(:status_groups) } end - + # General Properties # ========================================================================================== # The title of the group, i.e. a kind of caption, e.g. used in the tag of the # webpage. By default, this returns just the name of the group. But this may be changed # in the main application. - # + # def title self.name end @@ -71,7 +71,7 @@ def title def name I18n.t( super.to_sym, default: super ) if super.present? end - + def extensive_name if has_flag? :attendees name + (parent_events.first ? ": " + parent_events.first.name : '') @@ -85,7 +85,7 @@ def extensive_name name end end - + def name_with_corporation if self.corporation && self.corporation.id != self.id "#{self.name} (#{self.corporation.name})" @@ -93,9 +93,9 @@ def name_with_corporation self.name end end - + # This sets the format of the Group urls to be - # + # # example.com/groups/24-planeswalkers # # rather than just @@ -105,8 +105,8 @@ def name_with_corporation def to_param "#{id} #{title}".parameterize end - - + + # Mark this group of groups, i.e. the primary members of the group are groups, # not users. This does not effect the DAG structure, but may affect the way # the group is displayed. @@ -117,25 +117,25 @@ def group_of_groups? def group_of_groups=(add_the_flag) add_the_flag ? add_flag(:group_of_groups) : remove_flag(:group_of_groups) end - - + + # Associated Objects # ========================================================================================== # Events # ------------------------------------------------------------------------------------------ - + def events - self.descendant_events + Event.find_all_by_group(self) end def upcoming_events self.events.upcoming.order('start_at') end - - + + # Adress Labels (PDF) - # options: + # options: # - sender: Sender line including sender address. # - book_rate: Whether the "Büchersendung"/"Envois à taxe réduite" badge # is to be printed. @@ -178,14 +178,14 @@ def corporation_id def corporation? kind_of? Corporation end - + def find_deceased_members_parent_group self.descendant_groups.where(name: ["Verstorbene", "Deceased"]).limit(1).first end def deceased find_deceased_members_parent_group end - + concerning :GroupDescendantUsers do def descendant_users raise('Changed interface! Please use `Group#members` or `Group#members.with_past`.') diff --git a/app/models/group_collection.rb b/app/models/group_collection.rb index 933ed8fa7..22413ae2a 100644 --- a/app/models/group_collection.rb +++ b/app/models/group_collection.rb @@ -1,41 +1,49 @@ class GroupCollection - + def initialize(attrs = {}) @memberships = attrs[:memberships] || raise('no memberships (MembershipCollection) given.') @memberships.kind_of?(MembershipCollection) || raise('memberships needs to be a MembershipCollection.') end - + def to_a groups = @memberships.to_a.collect { |membership| membership.group } groups = groups & Group.flagged(@flagged) if @flagged return groups end - + def flagged(flag) @flagged = flag return self end - + def find_all_by_flag(flag) flagged(flag) end - + def now @memberships = @memberships.now return self end - + def with_past @memberships = @memberships.with_past return self end - + def past @memberships = @memberships.past return self end - + + def ids + self.map(&:id) + end + + def where + Group.where(id: ids) + end + delegate :count, :first, :last, to: :to_a - delegate :map, :collect, :select, :detect, :include?, :+, :-, :&, to: :to_a - + delegate :map, :collect, :select, :detect, :include?, :any?, :+, :-, :&, to: :to_a + end \ No newline at end of file diff --git a/app/models/group_mixins/guests.rb b/app/models/group_mixins/guests.rb index 3fbe8e6cf..fe8f7390b 100644 --- a/app/models/group_mixins/guests.rb +++ b/app/models/group_mixins/guests.rb @@ -1,7 +1,7 @@ # # All groups have associated special groups, for example the `guests_parent` group, which -# contains all guests of the group. -# +# contains all guests of the group. +# # This mixin provides the accessor methods for the guests_parent special group. # # The mechanism used in the mixin is defined in `StructureableMixins::HasSpecialGroups`. @@ -13,8 +13,8 @@ module GroupMixins::Guests included do # see, for example, http://stackoverflow.com/questions/5241527/splitting-a-class-into-multiple-files-in-ruby-on-rails end - - + + # Guests # ========================================================================================== @@ -39,7 +39,7 @@ def guests_parent! end def find_guest_users - guests_parent.descendant_users + guests_parent.members end def guests @@ -57,5 +57,5 @@ def guests def find_guests_groups find_guests_parent_group.descendant_groups end - -end + +end diff --git a/app/models/group_mixins/roles.rb b/app/models/group_mixins/roles.rb index 4aa736718..64727e6c4 100644 --- a/app/models/group_mixins/roles.rb +++ b/app/models/group_mixins/roles.rb @@ -27,15 +27,15 @@ module GroupMixins::Roles # some_group.administrated_object == nil # def administrated_object - if self.ancestor_groups.find_all_by_flag( :officers_parent ).count == 0 and - not self.has_flag? :officers_parent - return nil - end object = self - until object.has_flag? :officers_parent + counter = 0 + until object.has_flag?(:officers_parent) object = object.parents.first + return nil if object.nil? + counter += 1 + counter < 5 || raise('This, aparently is no admins group.') end object = object.parents.first end - + end diff --git a/app/models/list_export.rb b/app/models/list_export.rb index ba3ecb5f8..82ba63c62 100644 --- a/app/models/list_export.rb +++ b/app/models/list_export.rb @@ -1,3 +1,5 @@ +require 'csv' + # This class helps to export data to CSV, XLS and possibly others. # # Example: @@ -17,10 +19,10 @@ # * https://github.com/zdavatz/spreadsheet # * Formatting xls: http://scm.ywesee.com/?p=spreadsheet/.git;a=blob;f=lib/spreadsheet/format.rb # * to_xls gem example: http://stackoverflow.com/questions/15600987/ -# +# class ListExport attr_accessor :data, :preset, :csv_options - + def initialize(initial_data, initial_preset = nil) @data = initial_data; @preset = initial_preset @csv_options = { col_sep: ';', quote_char: '"' } @@ -28,13 +30,13 @@ def initialize(initial_data, initial_preset = nil) @data = processed_data @data = sorted_data end - + def columns case preset.to_s when 'address_list' [:last_name, :first_name, :name_affix, :postal_address_with_name_surrounding, - :postal_address, :cached_localized_postal_address_updated_at, - :postal_address_street, + :postal_address, :cached_localized_postal_address_updated_at, + :postal_address_street, :postal_address_postal_code, :postal_address_town, :postal_address_state, :postal_address_country, :postal_address_country_code, @@ -55,7 +57,7 @@ def columns [:last_name, :first_name, :name_affix, :personal_title, :academic_degree] end end - + def headers columns.collect do |column| if column.kind_of? Symbol @@ -65,15 +67,15 @@ def headers end end end - + def processed_data if preset.to_s.in?(['birthday_list', 'address_list', 'dpag_internetmarke', 'phone_list', 'email_list']) && @data.kind_of?(Group) - # To be able to generate lists from Groups as well as search results, these presets expect + # To be able to generate lists from Groups as well as search results, these presets expect # an Array of Users as data. If a Group is given instead, just take the group members as data. # @data = @data.members end - + # Make the extended methods available that are defined below. # if @data.respond_to?(:first) && @data.first.kind_of?(User) @@ -124,7 +126,7 @@ def processed_data # /FIXME - please uncomment: #@leaf_group_names = @leaf_groups.pluck(:name) #@leaf_group_ids = @leaf_groups.pluck(:id } - + @group.members.collect do |user| user = user.becomes(ListExportUser) row = { @@ -146,7 +148,7 @@ def processed_data # # From a list of groups, this creates one row per group. # The columns count the number of memberships valid from the year given by the column. - # + # # For the 'join_and_persist_statistics', only memberships are counted # that are still valid, i.e. still persist. # @@ -154,7 +156,7 @@ def processed_data # group1 24 22 25 28 ... # group2 31 28 27 32 ... # ... - # + # if @data.kind_of? Group @groups = @data.child_groups elsif @data.kind_of? Array @@ -164,7 +166,7 @@ def processed_data row = {} columns.each do |column| row[column] = if column.kind_of? Integer - year = column + year = column memberships = [] if preset.to_s == 'join_statistics' memberships = group.memberships.with_past @@ -204,16 +206,16 @@ def sorted_data data end end - + def raise_error_if_data_is_not_valid case preset.to_s when 'birthday_list', 'address_list', 'dpag_internetmarke', 'phone_list', 'email_list', 'name_list' data.kind_of?(Group) || data.first.kind_of?(User) || raise("Expecing Group or list of Users as data in ListExport with the preset '#{preset}'.") when 'member_development' data.kind_of?(Group) || raise('The member_development list can only be generated for a Group, not an Array of Users.') - end + end end - + def to_csv CSV.generate(csv_options) do |csv| csv << headers @@ -222,7 +224,7 @@ def to_csv if row.respond_to? :values row[column_name] elsif row.respond_to? column_name - row.try(:send, column_name) + row.try(:send, column_name) else raise "Don't know how to access the given attribute or value. Trying to access '#{column_name}' on '#{row}'." end @@ -230,13 +232,13 @@ def to_csv end end end - + def to_xls header_format = {weight: 'bold'} @data = @data.collect { |hash| HashWrapper.new(hash) } if @data.first.kind_of? Hash @data.to_xls(columns: columns, headers: headers, header_format: header_format) end - + def to_html (" <table class='datatable joining statistics'> @@ -253,17 +255,17 @@ def to_html </table> ").html_safe end - + def to_a @data end - + def to_s to_csv end - + private - + def helpers ActionController::Base.helpers end @@ -276,11 +278,11 @@ class HashWrapper def initialize(hash) @hash = hash end - + # This is a workaround for the to_xls gem, which requires to access the attributes # by method in order to write the columns in the correct order. # - def method_missing(method_name, *args, &block) + def method_missing(method_name, *args, &block) @hash[method_name] || @hash[method_name.to_sym] end end @@ -291,11 +293,11 @@ def method_missing(method_name, *args, &block) # require 'user' class ListExportUser < User - + def personal_title_and_name "#{personal_title} #{name}".strip end - + # Birthday, Date of Birth, Date of Death # def current_age @@ -307,7 +309,7 @@ def localized_birthday_this_year def localized_date_of_birth I18n.localize date_of_birth if date_of_birth end - + # Address # def postal_address_with_name_surrounding @@ -361,7 +363,7 @@ def address_label_text_after_name def dpag_postal_address_type "HOUSE" end - + def cache_key # Otherwise the cached information of the user won't be used. super.gsub('list_export_users/', 'users/') diff --git a/app/models/list_exports/base.rb b/app/models/list_exports/base.rb index d42383b21..9a608e707 100644 --- a/app/models/list_exports/base.rb +++ b/app/models/list_exports/base.rb @@ -1,3 +1,5 @@ +require 'csv' + # This class helps to export data to CSV, XLS and possibly others. # # Example: @@ -17,33 +19,33 @@ # * https://github.com/zdavatz/spreadsheet # * Formatting xls: http://scm.ywesee.com/?p=spreadsheet/.git;a=blob;f=lib/spreadsheet/format.rb # * to_xls gem example: http://stackoverflow.com/questions/15600987/ -# +# module ListExports class Base - + # The data that is to be exported is supposed to be some kind of Array. # The array can contain ActiveRecord objects or Hashes. # # The data array is filled, either in the initializer, or in a `from_xyz` method. # attr_accessor :data - + # This is a way to store export options when initializing. # attr_accessor :options - + def initialize(data, options = {}) @data = data @options = options end - + # Initialize from group, i.e. the group members are considered to be the # export data. # def self.from_group(group, options = {}) self.new(group.members.to_a, options.merge({group: group})) end - + # The columns that are to be exported are listed here as array of Symbols or Strings. # During the export, these names are used either as methods on the ActiveRecord objects, # or as keys for the Hashes in the `data`. @@ -51,7 +53,7 @@ def self.from_group(group, options = {}) def columns [] end - + # The headers of the tables are, by default, derived from the columns # that are to be exported. # @@ -64,7 +66,7 @@ def headers end end end - + # Wrapping the `data` Array as array of `DataRow` objects # unifies the access method: The columns can be accessed using the # `column(key)` method. @@ -74,7 +76,7 @@ def data_rows DataRow.new(object) end end - + # This exports the `data` into a csv formatted String. # def to_csv @@ -87,14 +89,14 @@ def to_csv end end end - + def csv_options {col_sep: ';', quote_char: '"'} end - + # This exports the `data` into xls format, which can be served via - # - # send_data(@list_export.to_xls, type: 'application/xls; charset=utf-8; header=present', + # + # send_data(@list_export.to_xls, type: 'application/xls; charset=utf-8; header=present', # filename: "#{@file_title}.xls") # # Internally, we use the to_xls gem: @@ -103,6 +105,6 @@ def csv_options def to_xls data_rows.to_xls(columns: columns, headers: headers, header_format: {weight: 'bold'}) end - + end end \ No newline at end of file diff --git a/app/models/role.rb b/app/models/role.rb index 8e8e54c9c..539868494 100644 --- a/app/models/role.rb +++ b/app/models/role.rb @@ -1,5 +1,5 @@ # Example: -# +# # Role.of(user).in(corporation).to_s # => "guest" # Role.of(user).in(corporation).guest? # => true # Role.find_all_by_user_and_group(user, corporation) @@ -8,14 +8,14 @@ # Role.of(user).for(user).to_s # => "global_admin" # class Role - + def initialize(given_user, given_object) @user = given_user @object = given_object end - + # Example: - # + # # Role.of(user).in(corporation).to_s # => "guest" # def self.of(given_user) @@ -23,7 +23,7 @@ def self.of(given_user) end # Example: - # + # # Role.of(user).in(corporation).to_s # => "guest" # def in(given_object) @@ -33,26 +33,26 @@ def in(given_object) def for(given_object) self.in(given_object) end - + def user @user || raise('User not given, when trying to determine Role.') end - + def object @object end def group @object if @object.kind_of?(Group) end - + # # Roles for groups # - + def current_member? member? && full_member? end - + # To be a full member of a `group`, a `user` has # (a) to be member of the `group` and the `group` has to be flagged `:full_members`. # (b) to be member of one of the subgroups of `group` that is flagged `:full_members`. @@ -62,27 +62,27 @@ def full_member? full_members_group_ids = ([group.id] + group.connected_descendant_group_ids) & Group.flagged(:full_members).pluck(:id) (user.groups.map(&:id) & full_members_group_ids).count > 0 end - + def member? object && object.kind_of?(Group) && user.member_of?(object) end - + def guest? object && object.kind_of?(Group) && user.guest_of?(object) end - + def former_member? object && object.kind_of?(Group) && object.corporation? && user.former_member_of_corporation?(object) end - + def deceased_member? object && object.kind_of?(Group) && object.corporation? && user.id.in?(object.deceased_members.map(&:id)) && (not former_member?) end - + # # Roles for structureables # - + def global_admin? user.global_admin? end @@ -90,12 +90,12 @@ def global_admin? def admin? global_admin? || (object && object.admins_of_self_and_ancestors.include?(user)) end - + def officer? global_admin? || (object && object.officers_of_self_and_ancestors.include?(user)) end - - + + # Example # Role.of(user).for(page).to_s # => 'admin' def to_s @@ -110,17 +110,17 @@ def to_s return 'member' if member? return '' end - + # # Global Roles # def global_officer? global_admin? || (user.ancestor_groups.find_all_by_flag(:global_officer).count > 0) end - + # The system allows to simulate a certain role when viewing an object. # This determines which simulations are allowed. - # + # def allowed_preview_roles return ['global_admin', 'admin', 'officer', 'global_officer', 'user'] if global_admin? return ['admin', 'officer', 'user'] if admin? @@ -134,7 +134,7 @@ def allow_preview? # All above roles are also officer roles. officer? || global_officer? end - + # Finding administrated objects. # # Role.of(user).select_objects_where_user_is_admin(objects) @@ -156,14 +156,14 @@ def administrated_objects directly_administrated_objects + directly_administrated_objects.collect { |o| o.descendants }.flatten end def administrated_users - directly_administrated_groups.collect { |g| g.descendant_users }.flatten + directly_administrated_groups.collect { |g| g.members.to_a }.flatten.uniq end - - + + # This finder method returns all global admins. # def self.global_admins Group.find_everyone_group.try(:find_admins) || [] end - + end \ No newline at end of file diff --git a/app/models/structureable.rb b/app/models/structureable.rb index 7c3ee7880..d4bf30b9f 100644 --- a/app/models/structureable.rb +++ b/app/models/structureable.rb @@ -74,6 +74,7 @@ def is_structureable( options = {} ) include StructureableConnectedGroups include StructureableConnectedLeafGroups include StructureableConnectedPages + include StructureableConnectedDescendants # Methods to manage the graph-related cache. # diff --git a/app/models/structureable_mixins/roles.rb b/app/models/structureable_mixins/roles.rb index 3f088bc67..b60f458ef 100644 --- a/app/models/structureable_mixins/roles.rb +++ b/app/models/structureable_mixins/roles.rb @@ -14,7 +14,7 @@ module StructureableMixins::Roles included do end - + def fill_cache super if respond_to?(:child_groups) # TODO: Refactor this. It should be possible to find the admins for a user. @@ -27,16 +27,16 @@ def fill_cache officers_of_self_and_ancestor_groups end end - + def delete_cache super delete_caches_concerning_roles end - + def delete_caches_concerning_roles - + # TODO: - + ### if self.class.base_class.name == 'Group' ### # For an admins_parent, this is called recursively until the original group ### # is reached. @@ -47,7 +47,7 @@ def delete_caches_concerning_roles ### # |------------ some officer group ### # ### if has_flag?(:officers_parent) || has_flag?(:admins_parent) - ### parent_groups.each do |group| + ### parent_groups.each do |group| ### group.delete_cache ### if group.descendants.count > 0 ### bulk_delete_cached :admins_of_ancestors, group.descendants @@ -58,7 +58,7 @@ def delete_caches_concerning_roles ### end ### end end - + # Officers # ========================================================================================== @@ -94,12 +94,12 @@ def officers_parent def officers_parent! find_officers_parent_group || raise('special group :officers_parent does not exist.') end - - + + def descendant_officer_groups self.descendant_groups.where(type: 'OfficerGroup') end - + def create_officer_group(attrs = {name: "New Office"}) g = officers_parent.child_groups.create(attrs) g.update_attribute :type, "OfficerGroup" @@ -126,17 +126,17 @@ def officers_groups def officer_groups self.officers_groups end - + def direct_officers self.find_officers_parent_group.try(:descendant_users) || [] end - + def officers_of_self_and_parent_groups cached do direct_officers + (parent_groups.collect { |parent_group| parent_group.direct_officers }.flatten) end end - + def officers_groups_of_self_and_descendant_groups cached do self.find_officers_parent_groups_of_self_and_of_descendant_groups.collect do |officers_parent| @@ -144,27 +144,27 @@ def officers_groups_of_self_and_descendant_groups end.flatten.uniq end end - + def find_officers cached do if respond_to? :child_groups - find_officers_parent_group.try(:descendant_users) + find_officers_parent_group.try(:members) end || [] end end def officers_of_ancestors - cached { ancestors.collect { |ancestor| ancestor.find_officers }.flatten } + cached { ancestors.collect { |ancestor| ancestor.find_officers.to_a }.flatten } end - + def officers_of_ancestor_groups - cached { ancestor_groups.collect { |ancestor| ancestor.find_officers }.flatten } + cached { ancestor_groups.collect { |ancestor| ancestor.find_officers.to_a }.flatten } end - + def officers_of_self_and_ancestors cached { find_officers + officers_of_ancestors } end - + def officers_of_self_and_ancestor_groups cached { find_officers + officers_of_ancestor_groups } end @@ -173,7 +173,7 @@ def officers_of_self_and_ancestor_groups # def officers self.find_officers_parent_groups_of_self_and_of_descendant_groups.collect do |officers_parent| - officers_parent.descendant_users + officers_parent.members.to_a end.flatten.uniq end @@ -223,29 +223,29 @@ def admins_parent! end def admins - find_or_create_admins_parent_group.try( :descendant_users ) || [] + find_or_create_admins_parent_group.try(:members) || [] end def find_admins cached do if respond_to? :child_groups - find_admins_parent_group.try( :descendant_users ) + find_admins_parent_group.try(:members) end || [] end || [] end - + def admins_of_ancestors cached { ancestors.collect { |ancestor| ancestor.find_admins }.flatten } end - + def admins_of_ancestor_groups cached { ancestor_groups.collect { |ancestor| ancestor.find_admins }.flatten } end - + def admins_of_self_and_ancestors cached { find_admins + admins_of_ancestors } end - + def responsible_admins # responsible are: local admins + last global admin: cached { (admins_of_self_and_ancestors - Group.global_admins.members[0..-2]).uniq } @@ -298,7 +298,7 @@ def main_admins_parent! end def main_admins - main_admins_parent.descendant_users + main_admins_parent.members end end diff --git a/spec/models/ability_spec.rb b/spec/models/ability_spec.rb index f7c758a65..31fc9a417 100644 --- a/spec/models/ability_spec.rb +++ b/spec/models/ability_spec.rb @@ -1,9 +1,9 @@ require 'spec_helper' require 'cancan/matchers' -# In order to call the user "he" rather than "it", +# In order to call the user "he" rather than "it", # we have to define an alias here. -# +# # http://stackoverflow.com/questions/12317558/alias-it-in-rspec # RSpec.configure do |c| @@ -11,13 +11,13 @@ end describe Ability do - + # I'm sorry. I do have problems with cancan's terminology, here. - # For me, the User can do something, i.e. I would ask + # For me, the User can do something, i.e. I would ask # # @user.can? :manage, @page # - # But for cancan, it's + # But for cancan, it's # # Ability.new(@user).can? :manage, @page # @@ -30,7 +30,7 @@ let(:ability) { Ability.new(user) } subject { ability } let(:the_user) { subject } - + context "(posts and comments)" do context "when the user is a member of a group" do before { @group = create(:group); @group.assign_user(user, at: 1.month.ago) } @@ -71,7 +71,7 @@ end end end - + context "(mentions)" do context "when mentioned in a comment" do before do @@ -102,47 +102,48 @@ he { should be_able_to :download, @post_attachment } end end - + context "when the user is global admin" do before { user.global_admin = true } - + he "should not be able to destroy events that are older than 10 minutes" do @event = create :event, name: "Recent Event" @event.update_attribute :created_at, 11.minutes.ago - + the_user.should_not be_able_to :destroy, @event end - + he "should be able to destroy recently created pages" do @page = create :page, title: "New Page" - + the_user.should be_able_to :destroy, @page end he "should not be able to destroy pages that are older than 10 minutes" do @page = create :page, title: "Old Page" @page.update_attribute :created_at, 11.minutes.ago - + the_user.should_not be_able_to :destroy, @page end - + he "should be able to change the 'hidden' attribute of any user" do @other_user = create :user the_user.should be_able_to :change_hidden, @other_user end end - + context "when the user is a group admin" do before do @group = create :group + @group << user @group.admins << user time_travel 2.seconds end - + he "should be able to update its profile fields" do @profile_field = @group.profile_fields.create(type: 'ProfileFieldTypes::Phone', value: '123') the_user.should be_able_to :update, @profile_field end - + he "should not be able to change the 'hidden' attribute of the group members" do @other_user = create :user; @group << @other_user the_user.should_not be_able_to :change_hidden, @other_user @@ -153,7 +154,7 @@ he { should be_able_to :update, user } he { should be_able_to :change_status, user } end - + context "when the user is officer of a group" do before do @group = create :group @@ -164,12 +165,12 @@ @parent_group = @group.parent_groups.create(name: "Parent Group") @unrelated_group = create :group end - + he "should not be able to update its profile fields" do @profile_field = @group.profile_fields.create(type: 'ProfileFieldTypes::Phone', value: '123') the_user.should_not be_able_to :update, @profile_field end - + describe "(events)" do he "should be able to create an event in his group" do the_user.should be_able_to :create_event, @group @@ -195,14 +196,14 @@ end he "should be able to destroy just created events in his domain" do @event = @group.child_events.create name: "Special Event" - + user.should be_in @group.officers_of_self_and_ancestors the_user.should be_able_to :destroy, @event end he "should not be able to destroy events that are older than 10 minutes" do @event = @group.child_events.create name: "Recent Event" @event.update_attribute :created_at, 11.minutes.ago - + the_user.should_not be_able_to :destroy, @event end he "should be able to invite users to an event" do @@ -216,13 +217,13 @@ end end end - + describe "when the user is a page admin" do before do @page = create :page @page.admins << user end - + he { should be_able_to :create_page_for, @page } he "should be able to destroy the sub-page" do @sub_page = @page.child_pages.create @@ -234,14 +235,14 @@ the_user.should_not be_able_to :destroy, @sub_page end end - + describe "when the user is a page officer" do before do @page = create :page @secretary = @page.officers_parent.child_groups.create name: 'Secretary' @secretary << user end - + he { should be_able_to :create_page_for, @page } he "should be able to destroy the sub-page" do @sub_page = @page.child_pages.create @@ -253,14 +254,14 @@ the_user.should_not be_able_to :destroy, @sub_page end end - + describe "when the user is contact person of an event" do before do @event = create :event @event.contact_people_group.assign_user user, at: 2.minutes.ago end he { should be_able_to :create_page_for, @event } - + describe "when he is author of a subpage of the event" do before do @page = @event.child_pages.create @@ -269,7 +270,7 @@ end he { should be_able_to :update, @page} he { should be_able_to :create_attachment_for, @page } - + describe "when he is author of an attachment" do before do @attachment = @page.attachments.create @@ -278,7 +279,7 @@ end he { should be_able_to :update, @attachment } he { should be_able_to :destroy, @attachment } - + describe "when the user is also a global officer (bug fix)" do before do @global_officers = create :group @@ -286,7 +287,7 @@ @global_officers.assign_user user, at: 1.year.ago end let(:ability) { Ability.new(user, preview_as: 'global_officer') } - + he { should be_able_to :update, @attachment } he { should be_able_to :destroy, @attachment } end @@ -310,14 +311,14 @@ @secretary = Group.everyone.create_officer_group name: 'Secretary' @secretary.add_flag :global_officer @secretary.assign_user user, at: 1.month.ago - + @any_group = create :group end he { should be_able_to :create_event_for, @any_group } - + he "should be able to post to any group" do @any_group = create :group - + the_user.should be_able_to :create_post, @any_group the_user.should be_able_to :create_post_for, @any_group the_user.should be_able_to :create_post_via_email, @any_group @@ -329,21 +330,21 @@ @event = @any_group.child_events.create @event.contact_people << user end - + he { should be_able_to :update, @event } he { should be_able_to :invite_to, @event } end end end - + describe "for users without account" do let(:user) { create(:user) } let(:ability) { Ability.new(user) } subject { ability } let(:the_user) { subject } - - + + describe "(public pages)" do before do @root = Page.find_or_create_root @@ -352,11 +353,11 @@ @some_public_page = @root.child_pages.create title: 'This page is public.' Page.public_website_page_ids(true) # reload cached ids end - + he "should be able to access the imprint page" do @page = create :page, title: "Imprint" @page.add_flag :imprint - + the_user.should be_able_to :read, @page end he { should be_able_to :read, Page.find_root } diff --git a/spec/models/ability_to_use_mailing_lists_spec.rb b/spec/models/ability_to_use_mailing_lists_spec.rb index 29a31c741..7abace45c 100644 --- a/spec/models/ability_to_use_mailing_lists_spec.rb +++ b/spec/models/ability_to_use_mailing_lists_spec.rb @@ -1,9 +1,9 @@ require 'spec_helper' require 'cancan/matchers' -# In order to call the user "he" rather than "it", +# In order to call the user "he" rather than "it", # we have to define an alias here. -# +# # http://stackoverflow.com/questions/12317558/alias-it-in-rspec # RSpec.configure do |c| @@ -11,15 +11,15 @@ end describe Ability do - + before { @group = create :group } - + describe "for users without account" do let(:user) { nil } let(:ability) { Ability.new(nil) } subject { ability } let(:the_user) { subject } - + describe "for regular @groups" do describe "sender filter" do describe '(empty)' do @@ -60,7 +60,7 @@ end end end - + describe "for the @group being an OfficerGroup" do before { @group.type = "OfficerGroup"; @group.save; @group = Group.find(@group.id) } @@ -75,7 +75,7 @@ end end end - + describe "for the @group having a corporation" do before do @corporation = create :corporation_with_status_groups @@ -94,14 +94,14 @@ end end end - - + + # I'm sorry. I do have problems with cancan's terminology, here. - # For me, the User can do something, i.e. I would ask + # For me, the User can do something, i.e. I would ask # # @user.can? :manage, @page # - # But for cancan, it's + # But for cancan, it's # # Ability.new(@user).can? :manage, @page # @@ -114,7 +114,7 @@ let(:ability) { Ability.new(user) } subject { ability } let(:the_user) { subject } - + describe "without the user being any member" do describe "for regular @groups" do describe "sender filter" do @@ -156,10 +156,10 @@ end end end - + describe "for the @group being an OfficerGroup" do before { @group.type = "OfficerGroup"; @group.save; @group = Group.find(@group.id) } - + describe "sender filter" do describe '(empty)' do before { @group.mailing_list_sender_filter = ""; @group.save } @@ -171,13 +171,13 @@ end end end - + describe "for the @group having a corporation" do before do @corporation = create :corporation_with_status_groups @corporation << @group end - + describe "sender filter" do describe '(empty)' do before { @group.mailing_list_sender_filter = ""; @group.save } @@ -190,14 +190,14 @@ end end end - + describe "for corporation members" do before do @corporation = create :corporation_with_status_groups @corporation.status_groups.first.assign_user user @corporation << @group end - + describe "sender filter" do describe '(empty)' do before { @group.mailing_list_sender_filter = ""; @group.save } @@ -236,10 +236,10 @@ he { should_not be_able_to :create_post_for, @group } end end - + describe "for the @group being an OfficerGroup" do before { @group.type = "OfficerGroup"; @group.save; @group = Group.find(@group.id) } - + describe "sender filter" do describe '(empty)' do before { @group.mailing_list_sender_filter = ""; @group.save } @@ -255,7 +255,7 @@ describe "for @group members" do before { @group.assign_user user } - + describe "sender filter" do describe '(empty)' do before { @group.mailing_list_sender_filter = ""; @group.save } @@ -304,7 +304,7 @@ @officer_group.assign_user user @group.assign_user user end - + describe "sender filter" do describe '(empty)' do before { @group.mailing_list_sender_filter = ""; @group.save } @@ -351,7 +351,7 @@ @officer_group = @other_group.create_officer_group name: 'President' @officer_group.assign_user user end - + describe "sender filter" do describe '(empty)' do before { @group.mailing_list_sender_filter = ""; @group.save } @@ -394,7 +394,7 @@ describe "for global officers" do # Currently, we've got an override in place (in the Ability model) - # that allows global officers to post to any group, even if not + # that allows global officers to post to any group, even if not # specified by the Group#mailing_list_sender_filter. before do @@ -406,7 +406,7 @@ @officer_group.add_flag :global_officer @officer_group.assign_user user end - + describe "sender filter" do describe '(empty)' do before { @group.mailing_list_sender_filter = ""; @group.save } diff --git a/spec/models/concerns/user_roles_spec.rb b/spec/models/concerns/user_roles_spec.rb index f37fd5562..2e9c89909 100644 --- a/spec/models/concerns/user_roles_spec.rb +++ b/spec/models/concerns/user_roles_spec.rb @@ -2,7 +2,7 @@ describe User do - before do + before do @user = create( :user ) @user.save end @@ -10,53 +10,59 @@ # Roles # ========================================================================================== - + describe "#role_for" do - before do - @object = create( :page ) - @object.create_main_admins_parent_group - @sub_object = create( :group ); @sub_object.parent_pages << @object - @sub_sub_object = create( :user ); @sub_sub_object.parent_groups << @sub_object - end - subject { @user.role_for @object } - context "for the user being not related to the object" do - it { should == nil } - end - context "for the user being a member of the object" do + context "for pages" do before do - @group = create( :group ) - @group.child_users << @user - @object.child_groups << @group + @object = create( :page ) + @object.create_main_admins_parent_group + @sub_object = create( :group ); @sub_object.parent_pages << @object + @sub_sub_object = create( :user ); @sub_sub_object.parent_groups << @sub_object + end + subject { @user.role_for @object } + + context "for the user being not related to the object" do + it { should == nil } + end + context "for the user being an admin of the object" do + before { @object.admins << @user } + it { should == :admin } + end + context "for the user being a main_admin of the object" do + before { @object.main_admins << @user } + it { should == :main_admin } + end + context "for the object being not structureable" do + before { @object = "This is a string." } + it { should == nil } + end + context "for descendant objects of administrated objects" do + before { @object.admins << @user } + it "should return the inherited role" do + @user.role_for( @object ).should == :admin + @user.role_for( @sub_object ).should == :admin + @user.role_for( @sub_sub_object ).should == :admin + end end - it { should == :member } - end - context "for the user being an admin of the object" do - before { @object.admins << @user } - it { should == :admin } - end - context "for the user being a main_admin of the object" do - before { @object.main_admins << @user } - it { should == :main_admin } - end - context "for the object being not structureable" do - before { @object = "This is a string." } - it { should == nil } end - context "for descendant objects of administrated objects" do - before { @object.admins << @user } - it "should return the inherited role" do - @user.role_for( @object ).should == :admin - @user.role_for( @sub_object ).should == :admin - @user.role_for( @sub_sub_object ).should == :admin + context "for groups" do + before do + @object = create(:group) + end + subject { @user.role_for @object } + + context "for the user being a member of the object" do + before { @object << @user } + it { should == :member } end end end - + # Admins # ------------------------------------------------------------------------------------------ - + describe "#admin_of" do - before do + before do @group = create( :group, name: "Directly Administrated Group" ) @group.find_or_create_admins_parent_group @group.admins_parent.child_users << @user @@ -64,7 +70,7 @@ subject { @user.admin_of } it { should == @user.administrated_objects } end - + describe "#admin_of?" do before do @group = create( :group, name: "Directly Administrated Group" ) @@ -103,7 +109,7 @@ it { should == false } end end - + describe "#directly_administrated_objects" do before do @group = create( :group, name: "Directly Administrated Group" ) @@ -118,7 +124,7 @@ end end end - + describe "#administrated_objects" do before do @group = create( :group, name: "Administrated Group" ) @@ -145,43 +151,39 @@ end end end - + # Main Admins # ------------------------------------------------------------------------------------------ - + describe "#main_admin_of?" do before do - @page = create( :page ) + @group = create(:group) end - subject { @user.main_admin_of? @page } + subject { @user.main_admin_of? @group } context "for the main_admins_parent_group existing" do - before { @page.create_main_admins_parent_group } + before { @group.create_main_admins_parent_group } context "for the user being a main admin of the object" do - before { @page.main_admins << @user } + before { @group.main_admins << @user } it { should == true } end context "for the user being just a regular admin of the object" do - before { @page.admins << @user } + before { @group.admins << @user } it { should == false } end context "for the user being just a regular member of the object" do - before do - @group = create( :group ) - @group.child_users << @user - @page.child_groups << @group - end + before { @group << @user } it "should be false" do - @user.member_of?( @page ).should be_true # just to make sure + @user.member_of?(@group).should be_true # just to make sure subject.should == false end end end end - - + + # Guest Status # ========================================================================================== - + describe "#guest_of?" do before { @group = create( :group ) } subject { @user.guest_of? @group } @@ -196,11 +198,11 @@ it { should == true } end end - - + + # Developers # ========================================================================================== - + describe "#developer?" do subject { @user.developer? } describe "for no developers group existing" do @@ -234,5 +236,5 @@ end end end - + end \ No newline at end of file diff --git a/spec/models/dag_link_spec.rb b/spec/models/dag_link_spec.rb deleted file mode 100644 index 0a2c1008c..000000000 --- a/spec/models/dag_link_spec.rb +++ /dev/null @@ -1,48 +0,0 @@ -require 'spec_helper' - -# The dag link functionality is tested extensively in the corresponding `acts-as-dag` gem. -# This test is just to make sure that the integration is propery done. Therefore, some basic scenarios are tested here. -# -# We use the Page model here to represent the dag's node objects, since it's a relatively simple model, which is already -# present in the database. If the Page model should become more extensive in the future, it's recommended to refactor -# this test to use a new model, perhaps defined in the test itself. -# -describe "Page (DagLinkNode)" do - - def setup_pages - @page = FactoryGirl.create( :page ) - @parent = FactoryGirl.create( :page ) - @grandfather = FactoryGirl.create( :page ) - @page.parent_pages << @parent - @parent.parent_pages << @grandfather - end - - before { setup_pages } - - describe "#ancestors" do - it "should return all ancestors, not only the parents" do - @page.ancestors.should include( @parent, @grandfather ) - end - end - - describe "#descendants" do - it "should return all descendants, not only the children" do - @grandfather.descendants.should include( @parent, @page ) - end - end - - describe "#parents" do - it "should return only the parents rather than all ancestors" do - @page.parents.should include( @parent ) - @page.parents.should_not include( @grandfather ) - end - end - - describe "#children" do - it "should return only the children rather than all descendants" do - @grandfather.children.should include( @parent ) - @grandfather.children.should_not include( @page ) - end - end - -end diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb index baedad8d0..e4606c068 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -55,8 +55,8 @@ @event.groups.should include @group, @another_group end end - - + + # Contact People and Attendees # ========================================================================================== @@ -76,7 +76,7 @@ @event.contact_people.should_not include @user end end - + describe "#attendees" do subject { @event.attendees } before { @user = create :user } @@ -105,7 +105,7 @@ # ========================================================================================== describe ".upcoming" do - before do + before do @upcoming_event = create( :event, start_at: 5.hours.from_now ) @recent_event = create(:event, start_at: 2.days.ago, end_at: 2.days.ago + 2.hours) @recent_event_today = create(:event, start_at: Date.today.to_datetime.change(hour: 0, min: 5)) @@ -140,8 +140,8 @@ end end - describe ".direct" do - # group_a + describe ".direct [removed]" do + # group_a # |----- event_0 # |----- group_b # | |------ event_1 @@ -159,15 +159,11 @@ end it "should list direct events" do @group_a.events.should include @event_0, @event_1, @event_2 - @group_a.events.direct.should include @event_0 - @group_a.events.direct.should_not include @event_1 - end - it "should commute with .find_all_by_group" do - Event.find_all_by_group( @group_a ).direct.to_a.should == - Event.direct.find_all_by_group( @group_a ).to_a + @group_a.child_events.should include @event_0 + @group_a.child_events.should_not include @event_1 end end - + # Finder Methods # ========================================================================================== @@ -196,12 +192,12 @@ describe ".find_all_by_groups" do before do - @group1 = create( :group ) - @event1 = @group1.events.create( :start_at => 5.hours.from_now ) - @group2 = create( :group ) - @event2 = @group2.events.create( :start_at => 2.hours.from_now ) - @group3 = create( :group ) - @event3 = @group3.events.create + @group1 = create :group + @event1 = @group1.child_events.create start_at: 5.hours.from_now + @group2 = create :group + @event2 = @group2.child_events.create start_at: 2.hours.from_now + @group3 = create :group + @event3 = @group3.child_events.create end subject { Event.find_all_by_groups( [ @group1, @group2 ] ) } it "should return the events of the given groups" do @@ -214,11 +210,11 @@ subject.first.start_at.should < subject.last.start_at end end - - + + # Structure # ========================================================================================== - + # The following DAG structure should be possible in the model layer (bug fix). # # @corporation @@ -241,7 +237,7 @@ @user = create :user @group = create :group @group.assign_user @user - + @event = Event.new @event.name ||= I18n.t(:enter_name_of_event_here) @event.start_at ||= Time.zone.now.change(hour: 20, min: 15) @@ -249,19 +245,19 @@ @event.parent_groups << @group @event.contact_people_group.assign_user @user end - - + + describe "#destroy" do subject { @event.destroy } - + it "should destroy the contact people and attendees groups as well" do @contact_people_group = @event.contact_people_group @attendees_group = @event.attendees_group - + subject Group.exists?(id: @contact_people_group.id).should == false Group.exists?(id: @attendees_group.id).should == false end end - + end diff --git a/spec/models/graph_performance_spec.rb b/spec/models/graph_performance_spec.rb index 53baa96d3..43682b3c9 100644 --- a/spec/models/graph_performance_spec.rb +++ b/spec/models/graph_performance_spec.rb @@ -119,8 +119,8 @@ def number_of_ancestor_group_members describe "graph performance: " do - $number_of_groups = 100 - $number_of_users = 10 + $number_of_groups = 10 + $number_of_users = 2 before :each do clear_db diff --git a/spec/models/group_mixins/guests_spec.rb b/spec/models/group_mixins/guests_spec.rb index 9a80eec4a..930480bda 100644 --- a/spec/models/group_mixins/guests_spec.rb +++ b/spec/models/group_mixins/guests_spec.rb @@ -8,7 +8,7 @@ describe "guests_parent_group" do before do - @container_group = create( :group ) + @container_group = create( :group ) @container_subgroup = create( :group ) # this is to test if subgroup's guests are NOT listed @container_subgroup.parent_groups << @container_group @guests_parent = @container_group.create_guests_parent_group @@ -45,7 +45,7 @@ subject.should include( @guests_sub1 ) end it "should NOT find the guests of the container group's subgroups" do - subject.should_not include( @guests_sub2 ) + subject.should_not include( @guests_sub2 ) end end @@ -59,7 +59,7 @@ describe "if the group does not have a guests_parent group" do subject { @other_group.find_guest_users } it "should still return an empty array" do - subject.should == [] + subject.should.to_a == [] end end end @@ -67,7 +67,7 @@ subject { @container_group } its( :guests_parent ) { should == @guests_parent } its( :guests_parent! ) { should == @guests_parent } - + its( :guests ) { should == @container_group.find_guest_users } end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index a549ab43c..ac8868e77 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -48,12 +48,12 @@ # Associated Objects # ========================================================================================== - + # Workflows # ------------------------------------------------------------------------------------------ describe "(Workflows)" do - # + # # @group # |---- @subgroup --- @subworkflow # |---- @workflow @@ -62,7 +62,7 @@ @group = create( :group ) @subgroup = create( :group ) @subgroup.parent_groups << @group - + @workflow = create( :workflow ) @workflow.parent_groups << @group @subworkflow = create( :workflow ) @@ -95,12 +95,12 @@ # ------------------------------------------------------------------------------------------ describe "(Events)" do - before do + before do @group = create( :group ) @subgroup = @group.child_groups.create - @upcoming_events = [ @group.events.create( start_at: 5.hours.from_now ), - @subgroup.events.create( start_at: 5.hours.from_now ) ] - @recent_events = [ @group.events.create( start_at: 2.days.ago ) ] + @upcoming_events = [ @group.child_events.create( start_at: 5.hours.from_now ), + @subgroup.child_events.create( start_at: 5.hours.from_now ) ] + @recent_events = [ @group.child_events.create( start_at: 2.days.ago ) ] @unrelated_events = [ create( :event ) ] end @@ -129,7 +129,7 @@ @name_match = "Group Name" @group = create( :group ) @group1 = create( :group, :name => @name_match ); @group1.parent_groups << @group - @group2 = create( :group, :name => "Other #{@name_match}" ); @group2.parent_groups << @group1 + @group2 = create( :group, :name => "Other #{@name_match}" ); @group2.parent_groups << @group1 @group3 = create( :group, :name => @name_match ); @group3.parent_groups << @group2 @matching_groups = [ @group1, @group3 ] @not_matching_groups = [ @group2 ] @@ -156,7 +156,7 @@ describe "for the group being a child of a corporation" do before { @group.parent_groups << @corporation } it "should return the parent" do - subject.should == @corporation + subject.should == @corporation end end describe "for the group being a descendant of a corporation" do @@ -174,7 +174,7 @@ end end end - + describe "#corporation?" do subject { @group.corporation? } describe "for the group being a corporation" do @@ -193,7 +193,7 @@ it { should == false } end end - + describe '#leaf_groups' do subject { @group.leaf_groups } describe 'for the group being a corporation' do @@ -217,7 +217,7 @@ @group_b = @group.child_groups.create @status_3 = @group_b.child_groups.create end - it 'should contain all status groups' do + it 'should contain all status groups' do should include(@status_1) should include(@status_2) should include(@status_3) @@ -257,7 +257,7 @@ @group = create(:corporation) @group.cached(:leaf_groups) wait_for_cache - + # The creation of this group structure should reset the cache. @status_1 = @group.child_groups.create @group_a = @group.child_groups.create @@ -277,7 +277,7 @@ describe "#<<" do before { @group = create(:group) } subject { @group << @object_to_add } - + describe "(user)" do before do @user = create(:user) @@ -293,7 +293,7 @@ UserGroupMembership.with_invalid.find_by_user_and_group(@user, @group).valid_from.should > 1.second.ago end end - + describe "(group)" do before do @subgroup = create(:group)