app/models/basket.rb

Summary

Maintainability
D
3 days
Test Coverage
# Kete means "basket" in the Te Reo Maori language
# using term basket here to spell out concept for developers
# and to avoid confusion with the kete app
class Basket < ActiveRecord::Base
  scope :except_certain_baskets, lambda { |baskets| where("id not in (?) AND status = 'approved'", baskets) }

  def self.settings
    # * EOIN: we are pretty sure this is not called - raise an exception to be sure
    # * FIXME: this is temporary and should be removed before we go into production
    raise 'died in Basket.settings'
  end

  # EOIN: TEMP: just while we are figuring out how this works
  def setting(name, *args)
    fail "Basket#setting expected just a name but we got the name '#{name}' and these extras: #{args}" unless args.empty?
    BasketSettings.get(name, *args)
  end

  def to_param
    urlified_name
  end

  # we use these for who can see what
  def self.member_level_options
    [
      [I18n.t('basket_model.basket_member'), 'at least member'],
      [I18n.t('basket_model.basket_moderator'), 'at least moderator'],
      [I18n.t('basket_model.basket_admin'), 'at least admin'],
      [I18n.t('basket_model.site_admin'), 'at least site admin']]
  end

  def self.user_level_options
    [[I18n.t('basket_model.all_users'), 'all users']] +
      Basket.member_level_options
  end

  def self.all_level_options
    [[I18n.t('basket_model.all_users'), 'all users']] +
      [[I18n.t('basket_model.logged_in'), 'logged in']] +
      Basket.member_level_options
  end

  def self.level_value_from(key)
    Basket.all_level_options.select { |v, k| k == key }.first.first
  end

  # profile forms, these should correspond to actions in the controller
  # really this would be nicer if it came from reflecting on the baskets_controller class
  def self.forms_options
    [
      [I18n.t('basket_model.basket_new_or_edit'), 'edit'],
      [I18n.t('basket_model.basket_appearance'), 'appearance'],
      [I18n.t('basket_model.basket_homepage_options'), 'homepage_options']]
  end

  # Editable Basket Attributes (copy from the basket database fields)
  EDITABLE_ATTRIBUTES = %w{
    index_page_redirect_to_all index_page_topic_is_entire_page
    index_page_link_to_index_topic_as index_page_basket_search index_page_image_as
    index_page_tags_as index_page_number_of_tags index_page_order_tags_by
    index_page_recent_topics_as index_page_number_of_recent_topics index_page_archives_as
    index_page_extra_side_bar_html private_default file_private_default allow_non_member_comments
    show_privacy_controls do_not_sanitize feeds_attributes }

  # Editable Basket Settings
  EDITABLE_SETTINGS = %w{
    fully_moderated moderated_except private_file_visibility browse_view_as
    sort_order_default sort_direction_reversed_default disable_site_recent_topics_display
    basket_join_policy memberlist_policy import_archive_set_policy allow_basket_admin_contact private_item_notification
    private_item_notification_show_title private_item_notification_show_short_summary
    theme_font_family header_image theme show_action_menu show_discussion show_flagging
    show_add_links side_menu_number_of_topics side_menu_ordering_of_topics side_menu_direction_of_topics
    additional_footer_content do_not_sanitize_footer_content replace_existing_footer }

  # Basket settings that are always editable or come under a parent option
  NESTED_FIELDS = %w{
    name status creator_id do_not_sanitize moderated_except
    sort_direction_reversed_default private_item_notification_show_title
    private_item_notification_show_short_summary index_page_link_to_index_topic_as
    index_page_recent_topics_as index_page_tags_as index_page_order_tags_by
    show_action_menu show_discussion show_flagging show_add_links
    side_menu_number_of_topics side_menu_ordering_of_topics side_menu_direction_of_topics
    do_not_sanitize_footer_content replace_existing_footer }

  # Kieran Pilkington, 2008-07-09
  # remove the roles from a basket before destroying it to prevent problems later on
  before_destroy :remove_users_and_roles

  # Kieran Pilkington, 2010-05-14
  # Make a quick class level var for some assignments below (to prevent multiple queries)
  # Note: NOT to be used outside this class! Used only when the application is loaded
  all_baskets = all

  def self.documentation_basket
    b = Basket.where(name: 'Documentation').first
    b or raise('Failed to find the required Documentation basket')
  end

  def self.about_basket
    b = Basket.where(name: 'About').first
    b or raise('Failed to find the required About basket')
  end

  def self.help_basket
    b = Basket.where(name: 'Help').first
    b or raise('Failed to find the required Help basket')
  end

  def self.site_basket
    b = Basket.where(name: 'Site').first
    b or raise('Failed to find the required Site basket')
  end

  # EOIN: possible future refactoring: make this return the actual baskets ???
  def self.standard_basket_ids
    [site_basket.id, help_basket.id, about_basket.id, documentation_basket.id]
  end

  def role(name)
    Role.where(name: name).where(authorizable_type: 'Basket').where(authorizable_id: id).last
  end

  def documentation_basket
    Basket.documentation_basket
  end

  def about_basket
    Basket.about_basket
  end

  def help_basket
    Basket.help_basket
  end

  def site_basket
    Basket.site_basket
  end

  def standard_basket_ids
    Basket.standard_basket_ids
  end

  # Kieran Pilkington, 2008/08/18
  # Store how many baskets have privacy controls enabled to determine
  # whether Site basket should keep its privacy browsing controls on
  # putting in the wrapper respond_to? logic so that upgrades of older Kete sites works
  if Basket.columns.any? { |c| c.name == 'show_privacy_controls' }
    scope :should_show_privacy_controls, lambda { where(show_privacy_controls: true) }
    cattr_accessor :privacy_exists
    @@privacy_exists = all_baskets.any? { |basket| basket.show_privacy_controls? }
    after_save :any_privacy_enabled_baskets?
    after_destroy :any_privacy_enabled_baskets?
  end
  # set up authorization plugin
  acts_as_authorizable

  # everything falls under one basket or another
  # we have a default basket for the site
  # can't use delete_all, throws off versioning
  # ZOOM_CLASSES.each do |zoom_class|
  #  has_many zoom_class.tableize.to_sym, :dependent => :destroy
  # end
  has_many :topics, dependent: :destroy
  has_many :comments, dependent: :destroy
  has_many :documents, dependent: :destroy
  has_many :videos, dependent: :destroy
  has_many :audio_recordings, dependent: :destroy
  has_many :still_images, dependent: :destroy
  has_many :web_links, dependent: :destroy

  # a topic may be the designated index page for it's basket
  has_one :index_topic, class_name: 'Topic', foreign_key: 'index_for_basket_id'

  # each basket was made by someone (admin or otherwise)
  belongs_to :creator, class_name: 'User'

  # imports are processes to bring in content to a basket
  has_many :imports, dependent: :destroy

  # each basket can have multiple feeds displayed in the sidebar
  has_many :feeds, dependent: :destroy
  accepts_nested_attributes_for :feeds, reject_if: proc { |attributes| attributes['url'].blank? }

  # each basket may have a profile (or in the future, possibly more than one)
  # that declares the rules of what options a basket admin may see/set
  # as opposed to a site administrator
  has_many :profile_mappings, as: :profilable, dependent: :destroy
  has_many :profiles, through: :profile_mappings

  validates_presence_of :name
  validates_uniqueness_of :name, case_sensitive: false

  # DEPRECIATED - urlified_name handles things correctly
  # and our xml escaping does the rest for plain old name attribute
  # don't allow special characters in label that will break our xml
  # validates_format_of :name, :with => /^[^\'\"<>\:\&,\?\}\{\/\\]*$/, :message => ": \', \\, /, &, \", <, and > characters aren't allowed"

  # we have an urlified_name attribute that hold the urlified version of the basket name
  before_save :urlify_name

  # Walter McGinnis, 2008-05-10
  # old versions of items have to updated to not point at non-existing basket
  before_destroy :clear_item_version_foreign_keys

  def update_index_topic(index_topic)
    if !index_topic.nil? && index_topic.is_a?(Topic)
      self.index_topic = index_topic
    elsif index_topic == 'destroy'
      self.index_topic = nil
    end
    save
  end

  # stuff related to taggings in a basket

  has_many :taggings, dependent: :destroy
  has_many :tags, through: :taggings

  # it's easy to get a basket's topics tag_counts
  # but we want all zoom_class's totals added together
  # special case is site basket
  # want to grab all tags from across all baskets
  def tag_counts_array(options = {})
    tag_limit = !options[:limit].nil? ? options[:limit] : index_page_number_of_tags
    tag_order = !options[:order].nil? ? options[:order] : index_page_order_tags_by
    tag_direction = ['asc', 'desc'].include?(options[:direction]) ? options[:direction] : (tag_order == 'alphabetical' ? 'asc' : 'desc')
    private_tags = options[:allow_private] || false

    return [] unless !tag_limit || tag_limit > 0 # false = no limit, 0 = no tags

    case tag_order
    when 'alphabetical'
      find_tag_order = "tags.name #{tag_direction}"
    when 'latest'
      find_tag_order = "taggings.created_at #{tag_direction}"
    when 'number'
      find_tag_order = "taggings_count #{tag_direction}"
    else
      # EOIN: the value here in old kete is :random but rails doesn't support that now (if it ever did)
      # TODO: do we really need to specify that the order should be random?
      find_tag_order = 'RANDOM()' # RAND() is mysql specific, on postgres it would be RANDOM()
    end

    tag_offset = (((options[:page] || 1) - 1) * tag_limit)

    conditions = "taggings.context = 'public_tags'"
    conditions += "taggings.context IN ('public_tags', 'private_tags')" if private_tags
    conditions += " AND taggings.basket_id = #{id}" unless self == site_basket

    tags = ActsAsTaggableOn::Tag.select('tags.id, tags.name, count(taggings.id) AS taggings_count')
                                .where(conditions)
                                .group('taggings.tag_id, tags.id, tags.name, taggings.created_at')
                                .joins(:taggings)
                                .order(find_tag_order)
                                .offset(tag_offset)
                                .limit(tag_limit)

    tags.map do |tag|
      {
        id: tag.id,
        name: tag.name,
        to_param: tag.to_param,
        total_taggings_count: tag.taggings_count
      }
    end
  end

  def tag_publicity_conditions(private_tags = false)
    conditions = "taggings.context = 'public_tags'"
    conditions = "taggings.context IN ('public_tags', 'private_tags')" if private_tags
    conditions += " AND taggings.basket_id = #{id}" unless self == site_basket
    conditions
  end

  def tag_counts_total(options)
    private_tags = options[:allow_private] || false

    tag_options = {
      select: 'distinct taggings.tag_id',
      joins: 'INNER JOIN taggings ON (tags.id = taggings.tag_id)',
      conditions: "taggings.context = 'public_tags'"
    }
    tag_options[:conditions] = "taggings.context IN ('public_tags', 'private_tags')" if private_tags
    tag_options[:conditions] += " AND taggings.basket_id = #{id}" unless self == site_basket

    ActsAsTaggableOn::Tag.count(tag_options)
  end

  # attribute options methods
  # TODO clean this up using define_method (meta programming magic)
  def show_flagging_as_options(site_basket, default = nil)
    current_show_flagging_value = default || setting(:show_flagging) || site_basket.setting(:show_flagging) || 'all users'
    select_options = array_to_options_list_with_defaults(Basket.user_level_options, current_show_flagging_value)
  end

  def show_add_links_as_options(site_basket, default = nil)
    current_show_add_links_value = default || setting(:show_add_links) || site_basket.setting(:show_add_links) || 'all users'
    select_options = array_to_options_list_with_defaults(Basket.user_level_options, current_show_add_links_value)
  end

  def show_action_menu_as_options(site_basket, default = nil)
    current_show_actions_value = default || setting(:show_action_menu) || site_basket.setting(:show_action_menu) || 'all users'
    select_options = array_to_options_list_with_defaults(Basket.user_level_options, current_show_actions_value)
  end

  def show_discussion_as_options(site_basket, default = nil)
    current_show_discussion_value = default || setting(:show_discussion) || site_basket.setting(:show_discussion) || 'all users'
    select_options = array_to_options_list_with_defaults(Basket.user_level_options, current_show_discussion_value)
  end

  def side_menu_ordering_of_topics_as_options(site_basket, default = nil)
    current_value = default || setting(:side_menu_ordering_of_topics) || site_basket.setting(:side_menu_ordering_of_topics) || 'updated_at'
    options_array = [['Latest', 'latest'], ['Alphabetical', 'alphabetical']]
    select_options = array_to_options_list_with_defaults(options_array, current_value)
  end

  def private_file_visibility_as_options(site_basket, default = nil)
    current_value = default || setting(:private_file_visibility) || site_basket.setting(:private_file_visibility) || 'at least member'
    select_options = array_to_options_list_with_defaults(Basket.member_level_options, current_value)
  end

  def private_file_visibilty_selected_or_default(value, site_basket)
    current_value = setting(:private_file_visibility) || site_basket.setting(:private_file_visibility) || 'at least member'
    value == current_value
  end

  # If setting is nil, be conservative.
  def allow_non_member_comments?
    allow_non_member_comments === true
  end

  # Privacy related methods, taking into account inheritance from Site Basket

  def show_privacy_controls_with_inheritance?
    (show_privacy_controls == true || (show_privacy_controls.nil? && site_basket.show_privacy_controls == true))
  end

  def private_default_with_inheritance?
    (private_default == true || (private_default.nil? && site_basket.private_default == true))
  end

  def file_private_default_with_inheritance?
    (file_private_default == true || (file_private_default.nil? && site_basket.file_private_default == true))
  end

  def private_file_visibility_with_inheritance
    setting(:private_file_visibility) || site_basket.setting(:private_file_visibility) || 'at least member'
  end

  def allow_non_member_comments_with_inheritance?
    (allow_non_member_comments == true || (allow_non_member_comments.nil? && site_basket.allow_non_member_comments == true))
  end

  def additional_footer_content_with_inheritance
    (!setting(:additional_footer_content).nil? && !setting(:additional_footer_content).to_s.squish.blank? ? setting(:additional_footer_content) : site_basket.setting(:additional_footer_content))
  end

  def replace_existing_footer_with_inheritance?
    (setting(:replace_existing_footer) == true || (setting(:replace_existing_footer).nil? && site_basket.setting(:replace_existing_footer) == true))
  end

  def memberlist_policy_or_default(default = nil)
    current_value = default || memberlist_policy_with_inheritance
    array_to_options_list_with_defaults(Basket.all_level_options, current_value, false)
  end

  def memberlist_policy_with_inheritance
    if !setting(:memberlist_policy).blank?
      setting(:memberlist_policy)
    elsif !site_basket.setting(:memberlist_policy).blank?
      site_basket.setting(:memberlist_policy)
    else
      'at least admin'
    end
  end

  def import_archive_set_policy_or_default(default = nil)
    current_value = default || import_archive_set_policy_with_inheritance
    array_to_options_list_with_defaults(Basket.member_level_options, current_value, false)
  end

  def import_archive_set_policy_with_inheritance
    if !setting(:import_archive_set_policy).blank?
      setting(:import_archive_set_policy)
    elsif !site_basket.setting(:import_archive_set_policy).blank?
      site_basket.setting(:import_archive_set_policy)
    else
      'at least admin'
    end
  end

  def browse_type_with_inheritance
    case setting(:browse_view_as)
    when 'inherit'
      site_basket.browse_type_with_inheritance
    when '' # if blank, return nil
      nil
    else
      setting(:browse_view_as)
    end
  end

  def private_item_notification_or_default(default = nil)
    current_value = default || setting(:private_item_notification) || site_basket.setting(:private_item_notification) || 'at least member'
    options = [[I18n.t('basket_model.private_item_notification_or_default.dont_send_notification'), 'do_not_email']] + Basket.member_level_options
    select_options = array_to_options_list_with_defaults(options, current_value, false, true)
  end

  def users_to_notify_of_private_item
    case setting(:private_item_notification)
    when 'at least member'
      has_site_admins_or_admins_or_moderators_or_members
    when 'at least moderator'
      has_site_admins_or_admins_or_moderators
    when 'at least admin'
      has_site_admins_or_admins
    else
      []
    end
  end

  def array_to_options_list_with_defaults(options_array, default_value, site_admin = true, pluralize = false)
    select_options = ''
    options_array.each do |option|
      label = option[0]
      value = option[1]
      next if label == I18n.t('basket_model.site_admin') && !site_admin
      select_options += "<option value=\"#{value}\""
      if default_value == value
        select_options += ' selected="selected"'
      end
      select_options += '>' + (pluralize ? label.pluralize : label) + '</option>'
    end
    select_options
  end

  # attribute options methods
  def self.link_to_index_topic_as_options
    [
      [I18n.t('basket_model.dont_link'), ''],
      [I18n.t('basket_model.details_and_comments'), 'full topic and comments'],
      [I18n.t('basket_model.only_details'), 'full topic'],
      [I18n.t('basket_model.only_comments'), 'comments']]
  end

  def self.recent_topics_as_options
    [
      [I18n.t('basket_model.recent_dont_show'), ''],
      [I18n.t('basket_model.recent_as_summaries'), 'summaries'],
      [I18n.t('basket_model.recent_as_headlines'), 'headlines']]
  end

  def self.archives_as_options
    [
      [I18n.t('basket_model.archives_dont_show'), ''],
      [I18n.t('basket_model.archives_by_type'), 'by type']]
  end

  def self.image_as_options
    [
      [I18n.t('basket_model.image_dont_show'), ''],
      [I18n.t('basket_model.image_latest'), 'latest'],
      [I18n.t('basket_model.image_random'), 'random']]
  end

  def self.order_tags_by_options
    [
      [I18n.t('basket_model.tags_ordered_most_popular'), 'number'],
      [I18n.t('basket_model.tags_ordered_by_name'), 'alphabetical'],
      [I18n.t('basket_model.tags_ordered_latest'), 'latest'],
      [I18n.t('basket_model.tags_ordered_random'), 'random']]
  end

  def self.tags_as_options
    [
      [I18n.t('basket_model.tags_as_categories'), 'categories'],
      [I18n.t('basket_model.tags_as_tag_cloud'), 'tag cloud']]
  end

  def moderation_select_options(default = nil)
    select_options = ''
    [
      [I18n.t('basket_model.moderate_before_approved'), true],
      [I18n.t('basket_model.moderate_on_flagged'), false]].each do |option|
      label = option[0]
      value = option[1]
      select_options += "<option value=\"#{value}\""
      if (default && (default == value.to_s || (default.blank? && value == false))) ||
         (!default && fully_moderated? == value)
        select_options += ' selected="selected"'
      end
      select_options += '>' + label + '</option>'
    end
    select_options
  end

  def fully_moderated?
    setting(:fully_moderated).blank? ? SystemSetting.default_policy_is_full_moderation : setting(:fully_moderated)
  end

  # if we don't have any moderators specified
  # find admins for basket
  # if no admins for basket, go with basket 1 (site) admins
  # if no admins for site, go with any site_admins
  def moderators_or_next_in_line
    moderators = has_moderators
    moderators = has_admins if moderators.size == 0
    moderators = Basket.site_basket.has_admins if moderators.size == 0
    moderators << Basket.site_basket.has_site_admins if moderators.size == 0 || SystemSetting.notify_site_admins_of_flaggings

    moderators.flatten.uniq
  end

  # all_disputed_revisions
  # all_reviewed_revisions
  # all_rejected_revisions
  %w{disputed reviewed rejected}.each do |type|
    define_method("all_#{type}_revisions") do
      revisions = ZOOM_CLASSES.collect do |zoom_class|
        send(zoom_class.tableize.to_sym).send("find_#{type}", id)
      end.flatten.compact
      revisions.sort_by { |item| item.flagged_at }
    end
  end

  def possible_themes
    @possible_themes = []
    themes_dir = Dir.new(THEMES_ROOT)
    themes_dir.each do |listing|
      path_to_theme_dir = THEMES_ROOT + '/' + listing
      if File.directory?(path_to_theme_dir) && !['.', '..', '.svn'].include?(listing)
        # needs to have at least a stylesheets directory
        # and an images directory with a sample in it under it
        @possible_themes << listing if File.exist?(path_to_theme_dir + '/stylesheets') && File.exist?(path_to_theme_dir + '/images/sample.jpg')
      end
    end
    @possible_themes
  end

  def font_family_select_options(default = nil)
    select_options = ''
    [
      [I18n.t('basket_model.font_use_theme_default'), ''],
      [I18n.t('basket_model.font_sans_serif'), 'sans-serif'],
      [I18n.t('basket_model.font_serif'), 'serif']].each do |option|
      label = option[0]
      value = option[1]
      select_options += "<option value=\"#{value}\""
      if (default && default == value) ||
         (!setting(:theme_font_family).blank? && setting(:theme_font_family) == value)
        select_options += ' selected="selected"'
      end
      select_options += '>' + label + '</option>'
    end
    select_options
  end

  # find if we should let users access the basket contact form
  # get the baskets setting or if nil, get it from the site basket
  def allows_contact_with_inheritance?
    (setting(:allow_basket_admin_contact) == true || (setting(:allow_basket_admin_contact).class == NilClass && site_basket.setting(:allow_basket_admin_contact) == true))
  end

  # return a boolean for whether basket join requests (with inheritance) are enabled
  # open / request = true
  # closed = false
  def allows_join_requests_with_inheritance?
    ['open', 'request'].include?(join_policy_with_inheritance)
  end

  # get the current basket join policy. If nil, use the site baskets
  def join_policy_with_inheritance
    !setting(:basket_join_policy).blank? ? setting(:basket_join_policy) : site_basket.setting(:basket_join_policy)
  end

  # get a list of administrators (including site administrators
  # if the current basket is the site basket)
  # uses auto-generated methods from the authorization plugin
  def administrators
    if self == site_basket
      has_site_admins_or_admins
    else
      has_admins
    end
  end

  # we need at least one site admin at all times
  def more_than_one_site_admin?
    has_site_admins.size > 1
  end

  # we need at least one admin in a basket at all times
  def more_than_one_basket_admin?
    has_admins.size > 1
  end

  def delete_roles_for(user)
    accepted_roles.each do |role|
      user.has_no_role(role.name, self)
    end
  end

  def related_images(type = :last)
    still_images.find_non_pending(type, PUBLIC_CONDITIONS) || {}
  end

  protected

  include FriendlyUrls

  # before save filter
  def urlify_name
    return if name.blank?

    formatted_name = name.to_s

    self.urlified_name = format_friendly_unicode_for(
      formatted_name,
      demarkator: '_',
      at_start: false,
      at_end: false
    )
  end

  def self.list_as_names_and_urlified_names
    all(select: 'name, urlified_name').collect { |basket| [basket.name, basket.urlified_name] }
  end

  private

  # when a basket is to be deleted
  # we have to update all versions for items that used to point
  # at the basket in question (but were previously moved out of basket)
  # we point them at the site basket and add to the item's version comment
  def clear_item_version_foreign_keys
    # work through the versions for each zoom_class
    ZOOM_CLASSES.each do |zoom_class|
      versions = Module.class_eval(zoom_class + '::Version').find_all_by_basket_id(self)
      versions.each do |version|
        new_version_comment = version.version_comment.nil? ? '' : version.version_comment + '. '
        new_version_comment += I18n.t('basket_model.now_in_site_basket', basket_name: name)

        version.update_attributes(basket_id: 1, version_comment: new_version_comment)
      end
    end
  end

  # when a basket is to be deleted
  # we have to remove the roles assigned to it
  # otherwise authorizable tries to get the basket which no longer exists
  # when called in user.basket_permissions
  def remove_users_and_roles
    accepted_roles.each do |role|
      # authentication plugin's accepts_no_role was problematic
      # rolling our own
      role.users.each { |user| user.drop(role) }
      role.reload
      role.destroy if role.users.size == 0
    end
  end

  # when we create, update, or destroy, we recalcutate the amount of
  # baskets with privacy controls enabled
  def any_privacy_enabled_baskets?
    @@privacy_exists = (Basket.should_show_privacy_controls.count > 0)
  end

  # James - 2008-12-10
  # Prevent site basket from being deleted
  before_destroy :prevent_site_basket_destruction

  def prevent_site_basket_destruction
    if self == site_basket
      raise I18n.t('basket_model.cannot_delete_site')
      false
    else
      true
    end
  end

  # Walter McGinnis, 2012-06-21
  # create redirect_registration if basket.urlified_name has changed
  # important this is called after urlify_name before_save
  before_update :register_redirect_if_necessary

  def register_redirect_if_necessary
    old_urlified_name = self.class.select('urlified_name').where(id: id).first.urlified_name
    if old_urlified_name != urlified_name
      RedirectRegistration.create!(source_url_pattern: "/#{old_urlified_name}/", target_url_pattern: "/#{urlified_name}/")
    end
  end
end