app/models/membership.rb
#
# In this application, all user group memberships, i.e. memberships of a certain
# user in a certain group, are stored implicitly in the dag_links table in order
# to minimize the number of database queries that are necessary to find out
# whether a user is member in a certain group through an indirect membership.
#
# This class allows abstract access to the Memberships themselves,
# and to their properties like since when the membership exists.
#
class Membership < DagLink
alias_attribute :user_id, :descendant_id
alias_attribute :user, :descendant
alias_attribute :group_id, :ancestor_id
alias_attribute :group, :ancestor
has_many :issues, as: :reference, dependent: :destroy
include MembershipCreator
include MembershipGapCorrection
def self.logger
# http://stackoverflow.com/a/337971/2066546
@membership_logger ||= Logger.new("#{Rails.root}/log/memberships.log")
end
def save(*args)
if valid_from_changed?
callstack = caller.select { |entry| entry.include?("app/") }.join(", ")
self.class.logger.info "Saving membership #{self.attributes.to_s}: #{callstack}"
end
super(*args)
end
# Validity Range
# ====================================================================================================
include MembershipValidityRange
include IndirectMembershipValidityRange
# May Need Review Flag
# ====================================================================================================
# Some memberships may contain information that need review, e.g. when a validity range
# was entered by assumption.
#
# This is stored as the flag :needs_review.
#
include Flags
include Review
# General Properties
# ====================================================================================================
# Title, e.g. 'Membership of John Doe in GroupXY'
#
def title
I18n.translate( :membership_of_user_in_group, user_name: self.user.title, group_name: self.group.name )
end
# Finder Class Methods
# ====================================================================================================
# Find all memberships that match the given parameters.
# This method returns an ActiveRecord::Relation object, which means that the result can
# be chained with scope methods.
#
# memberships = Membership.find_all_by( user: u )
# memberships = Membership.find_all_by( group: g )
# memberships = Membership.find_all_by( user: u, group: g ).now
# memberships = Membership.find_all_by( user: u, group: g ).in_the_past
# memberships = Membership.find_all_by( user: u, group: g ).now_and_in_the_past
#
def self.find_all_by( 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]
group = params[ :group ]
group ||= Group.find params[:group_id] if params[:group_id]
links = Membership
.where( :descendant_type => "User" )
.where( :ancestor_type => "Group" )
links = links.where( :descendant_id => user.id ) if user
links = links.where( :ancestor_id => group.id ) if group
links = links.order('valid_from')
return links
end
# Find the first membership that matches the parameters `params`.
# This is a shortcut for `find_all_by( params ).first`.
# Use this, if you only expect one membership to be found.
#
def self.find_by( params )
self.find_all_by( params ).limit( 1 ).first
end
def self.find_all_by_user( user )
self.find_all_by( user: user )
end
def self.find_all_by_group( group )
self.find_all_by( group: group )
end
def self.find_by_user_and_group( user, group )
self.find_by( user: user, group: group )
end
def self.find_all_by_user_and_group( user, group )
self.find_all_by( user: user, group: group )
end
def self.find_all
self.where(ancestor_type: "Group", descendant_type: "User")
end
# Access Methods to Associated User and Group
# ====================================================================================================
def user_title
user.try(:title)
end
def user_title=(new_user_title)
self.user = User.find_by_title(new_user_title)
end
def ensure_correct_ancestor_and_descendant_type
self.ancestor_type = 'Group'
self.descendant_type = 'User'
end
# Associated Corporation
# ====================================================================================================
# If this membership is a subgroup membership of a corporation, this method will return the
# corporation. Otherwise, this will return nil.
#
# corporation
# |-------- group
# |---( membership )---- user
#
# membership = Membership.find_by_user_and_group( user, group )
# membership.corporation == corporation
#
def corporation
if self.group && self.user
( ( self.group.ancestor_groups + [ self.group ] ) && self.user.corporations ).first
end
end
# Access Methods to Associated Direct Memberships
# ====================================================================================================
# Returns the direct memberships corresponding to this membership (self).
# For clarification, consider the following structure:
#
# group1
# |---- group2
# |---- user
#
# user is not a direct member of group1, but an indirect member. But user is a direct member of group2.
# Thus, this method, called on a membership of user and group1 will return the membership between
# user and group2.
#
# Membership.find_by( user: user, group: group1 ).direct_memberships.should
# include( Membership.find_by( user: user, group: group2 ) )
#
# An indirect membership can also have several direct memberships, as shown in this figure:
#
# group1
# |--------------- group2
# | |
# |---- group3 |
# |------------- user
#
# Here, group2 and grou3 are children of group1. user is member of group2 and group3.
# Hence, the indirect membership of user and group1 will include both direct memberships.
#
def direct_memberships(options = {})
descendant_groups_of_self_group = self.group.descendant_groups
descendant_group_ids_of_self_group = descendant_groups_of_self_group.pluck(:id)
group_ids = descendant_group_ids_of_self_group + [ self.group.id ]
memberships = Membership
if options[:with_invalid] || self.read_attribute( :valid_to )
# If the membership itself is invalidated, also consider the invalidated direct memberships.
# Otherwise, one has to call `direct_memberships_now_and_in_the_past` rather than
# `direct_memberships` in order to have the invalidated direct memberships included.
memberships = memberships.with_invalid
end
memberships = memberships
.find_all_by_user( self.user )
.where( :direct => true )
.where( :ancestor_id => group_ids, :ancestor_type => 'Group' )
memberships = memberships.order('valid_from')
memberships
end
def direct_memberships_now_and_in_the_past
direct_memberships(with_invalid: true)
end
# Returns the direct groups shown in the figures above in the description of
# `direct_memberships`.
#
def direct_groups
direct_memberships.collect { |membership| membership.group }
end
# Access Methods to Associated Indirect Memberships
# ====================================================================================================
def indirect_memberships
self.group.ancestor_groups.collect do |ancestor_group|
Membership.with_invalid.find_by_user_and_group(self.user, ancestor_group)
end.select do |item|
item != nil
end
end
# Methods to Change the Membership
# ====================================================================================================
# Destroy the current membership and move the user over to the given group.
#
# group1 group2
# |---- user => |---- user
#
# Due to the membership gap correction, the new membership is created before
# invalidating the last one, because the latest status membership is always
# open-ended.
#
def move_to_group(group_to_move_in, options = {})
time = (options[:time] || options[:date] || options[:at] || Time.zone.now).to_datetime
new_membership = group_to_move_in.assign_user self.user, at: time
invalidate at: time
return new_membership
end
def move_to(group, options = {})
move_to_group(group, options)
end
def promote_to( new_group, options = {} )
self.move_to_group( new_group, options )
end
end
# In order to have auto-loading of sti classes work correctly,
# we need to require the descendant classes of `Membership` here.
# Otherwise, calls like `Membership.all` won't include instances
# of the subclasses like `Memberships::Status` if they haven't
# been used previously.
#
# This has caused a serious bug previously, which is discussed in:
# https://trello.com/c/VvY1q6Cs/1127-strange-validity-ranges
#
# See also:
#
# - http://guides.rubyonrails.org/autoloading_and_reloading_constants.html#autoloading-and-sti
# - http://stackoverflow.com/q/3245838/2066546
# - http://stackoverflow.com/q/18506933/2066546
#
require 'memberships/status'