# encoding: utf-8 # # = Application Controller Base Class # # This is the base class for all the application's controllers. It contains # all the important application-wide filters and lots of helper methods. # Anything that appears here is available to every controller and view. # # == Filters # # browser_status:: Auto-detect browser capabilities (plugin). # autologin:: Determine which if any User is logged in. # set_locale:: Determine which language is requested. # check_user_alert:: Check if User has an alert to be displayed. # # == Methods # *NOTE*: Methods in parentheses are "private" helpers; you are encouraged to # use the public ones instead. # # ==== User authentication # autologin:: (filter: determine which user is logged in) # login_for_ajax:: (filter: minimal version of autologin for ajax) # check_permission:: Make sure current User is the right one. # check_permission!:: Same, but flashes "denied" message, too. # is_reviewer?:: Is the current User a reviewer? # is_in_admin_mode?:: Is the current User in admin mode? # has_unshown_notifications?:: # Are there pending Notification's of a given type? # check_user_alert:: (filter: redirect to show_alert if has alert) # set_autologin_cookie:: (set autologin cookie) # clear_autologin_cookie:: (clear autologin cookie) # set_session_user:: (store user in session -- id only) # get_session_user:: (retrieve user from session) # # ==== Internationalization # all_locales:: Array of available locales for which we have translations. # translate_menu:: Translate keys in select-menu's options. # set_locale:: (filter: determine which locale is requested) # standardize_locale:: # get_sorted_locales_from_request_header:: # (parse locale preferences from request header) # get_valid_locale_from_request_header:: # (choose locale that best matches request header) # # ==== Error handling # flash_notices?:: Are there any errors pending? # flash_get_notices:: Get list of errors. # flash_notice_level:: Get current notice level. # flash_clear:: Clear error messages. # flash_notice:: Add a success message. # flash_warning:: Add a warning message. # flash_error:: Add an error message. # flash_object_errors:: Add all errors for a given instance. # # ==== Name validation # guess_correct_name:: Check for alternative spellings of misspelt names. # create_needed_names:: Creates the given name if it's been approved. # construct_approved_names:: Creates a list of names if they've been approved. # construct_approved_name:: (helper) # save_names:: (helper) # save_name:: (helper) # # ==== Searching # clear_query_in_session:: Clears out Query stored in session below. # store_query_in_session:: Stores Query in session for use by create_species_list. # get_query_from_session:: Gets Query that was stored in the session above. # query_params:: Parameters to add to link_to, etc. for passing Query around. # set_query_params:: Make +query_params+ refer to a given Query. # pass_query_params:: Tell +query_params+ to pass-through the Query given to this action. # find_query:: Find a given Query or return nil. # find_or_create_query:: Find appropriate Query or create as necessary. # create_query:: Create a new Query from scratch. # redirect_to_next_object:: Find next object from a Query and redirect to its show page. # # ==== Indexes # show_index_of_objects:: Show paginated set of Query results as a list. # add_sorting_links:: Create sorting links for index pages. # find_or_goto_index:: Look up object by id, displaying error and redirecting on failure. # goto_index:: Redirect to a reasonable fallback (index) page in case of error. # paginate_letters:: Paginate an Array by letter. # paginate_numbers:: Paginate an Array normally. # # ==== Memory usage # log_memory_usage:: (filter: logs memory use stats from /proc/$$/smaps) # extra_gc:: (filter: calls ObjectSpace.garbage_collect) # count_objects:: (does... nothing??!!... for every Object that currently exists) # # ==== Other stuff # disable_link_prefetching:: (filter: prevents prefetching of destroy methods) # update_view_stats:: Called after each show_object request. # calc_layout_params:: Gather User's list layout preferences. # catch_errors (filter: catches errors for integration tests) # default_thumbnail_size:: Default thumbnail size: :thumbnail or :small. # set_default_thumbnail_size:: Change the default thumbnail size for the current user. # ################################################################################ class ApplicationController < ActionController::Base require 'extensions' require 'login_system' include LoginSystem around_filter :catch_errors if TESTING before_filter :fix_bad_domains before_filter :browser_status before_filter :autologin before_filter :set_locale before_filter :check_user_alert # before_filter :extra_gc # after_filter :extra_gc # after_filter :log_memory_usage # Enable this to test other layouts... layout :choose_layout def choose_layout change = params[:user_theme].to_s if !change.blank? if CSS.member?(change) if @user @user.theme = change @user.save else session[:theme] = change end else session[:layout] = change end end layout = session[:layout].to_s layout = 'application' if layout.blank? return layout end # Catch errors for integration tests. def catch_errors yield rescue => e @error = e raise e end # Redirect from www.mo.org to mo.org. # # This would be much easier to check if HTTP_HOST != DOMAIN, but if this ever # were to break we'd get into an infinite loop too easily that way. I think # this is a lot safer. BAD_DOMAINS would be something like: # # BAD_DOMAINS = [ # 'www.mushroomobserver.org', # 'mushroomobserver.com', # ] # # The importance of this is that browsers are storing different cookies # for the different domains, even though they are all getting routed here. # This is particularly problematic when a fully-specified link in, say, # a comment's body is different. This results in you having to re-login # when you click on these embedded links. # def fix_bad_domains if (request.method == :get) and BAD_DOMAINS.include?(request.env['HTTP_HOST']) redirect_to("#{HTTP_DOMAIN}#{request.request_uri}") end end ############################################################################## # # :section: User authentication # ############################################################################## # Filter that should run before everything else. Establishes whether a User # is logged in or not. # # Stores the currently logged-in User in the "globals" @user and # User.current, as well as the session. (The first is visible to # all controller instances and views; the second is visible to the entire # website application.) # # It first checks if the User is already logged in, i.e. is stored in the # session. If not, it checks for an autologin cookie on the User's browser, # and logs them in automatically if so. # # In both cases, it makes sure the User actually exists and is verified. If # not, the "user" is immediately logged out and the autologin cookie is # destroyed. # def autologin # render(:text => "Sorry, we've taken MO down to test something urgent. We'll be back in a few minutes. -Jason", :layout => false) # return false # Guilty until proven innocent... @user = nil User.current = nil # Disable everything to do with cookies for API controller. if controller_name != 'api' # Do nothing if already logged in: if user asked us to remember him the # cookie will already be there, if not then we want to leave it out. if (user = get_session_user) && (user.verified) @user = user @user.reload # Log in if cookie is valid, and autologin is enabled. elsif (cookie = cookies[:mo_user]) && (split = cookie.split(" ")) && (user = User.find(:first, :conditions => ['id = ?', split[0]])) && (split[1] == user.auth_code) && (user.verified) @user = set_session_user(user) @user.last_login = Time.now @user.save # Reset cookie to push expiry forward. This way it will continue to # remember the user until they are inactive for over a month. (Else # they'd have to login every month, no matter how often they login.) set_autologin_cookie(user) # Delete invalid cookies. else clear_autologin_cookie set_session_user(nil) end # Make currently logged-in user available to everyone. User.current = @user logger.warn("user=#{@user ? @user.id : '0'} robot=#{is_robot? ? 'Y' : 'N'}") # Keep track of last time user requested a page, but only update at most once an hour. if @user and ( !@user.last_activity or @user.last_activity.to_s('%Y%m%d%H') != Time.now.to_s('%Y%m%d%H') ) @user.last_activity = Time.now @user.save end end # Tell Rails to continue to process. return true end # Much-streamlined login "filter" used by AJAX methods that require login. # Just calls get_session_user, requires that the user already be logged in # and has user id stored in the session. def login_for_ajax get_session_user end # ---------------------------- # "Public" methods. # ---------------------------- # Is the current User the correct User (or is admin mode on)? Returns true # or false. (*NOTE*: this is available to views.) # # <% if check_permission(@object) # link_to('Destroy', :action => :destroy_object) # end %> # def check_permission(obj) result = false if is_in_admin_mode? result = true elsif obj.respond_to?(:user_id) and User.current_id == obj.user_id result = true elsif obj.respond_to?(:has_edit_permission?) and obj.has_edit_permission?(User.current) result = true elsif (obj.is_a?(String) or obj.is_a?(Fixnum)) and obj.to_i == User.current_id result = true end return result end helper_method :check_permission # Is the current User the correct User (or is admin mode on)? Returns true # or false. Flashes a "denied" error message if false. # # def destroy_thing # @thing = Thing.find(params[:id]) # if check_permission!(@thing) # @thing.destroy # flash_notice "Success!" # end # redirect_to(:action => :show_thing) # end # def check_permission!(obj) unless result = check_permission(obj) flash_error :permission_denied.t end return result end alias check_user_id check_permission! # Is the current User a reviewer? Returns true or false. (*NOTE*: this is # available to views.) def is_reviewer? result = false if @user result = @user.in_group?('reviewers') end result end alias is_reviewer is_reviewer? helper_method :is_reviewer helper_method :is_reviewer? # Is the current User in admin mode? Returns true or false. (*NOTE*: this # is available to views.) def is_in_admin_mode? @user && @user.admin && session[:admin] end helper_method :is_in_admin_mode? # Are there are any QueuedEmail's of the given flavor for the given User? # Returns true or false. # # This only applies to emails that are associated with Notification's for # which there is a note_template. (Only one type now: Notification's with # flavor :name, which corresponds to QueuedEmail's with flavor :naming.) # def has_unshown_notifications?(user, flavor=:naming) result = false for q in QueuedEmail.find_all_by_flavor_and_to_user_id(flavor, user.id) ints = q.get_integers(["shown", "notification"], true) unless ints["shown"] notification = Notification.find(ints["notification"].to_i) if notification and notification.note_template result = true break end end end result end # ---------------------------- # "Private" methods. # ---------------------------- # Before filter: check if the current User has an alert. If so, it redirects # to /account/show_alert. Returns true. def check_user_alert if @user && @user.alert && @user.alert_next_showing < Time.now && # Careful not to start infinite redirect-loop! action_name != 'show_alert' redirect_to(:controller => :account, :action => :show_alert) end return true end # Create/update the auto-login cookie. def set_autologin_cookie(user) cookies[:mo_user] = { :value => "#{user.id} #{user.auth_code}", :expires => 1.month.from_now } end # Destroy the auto-login cookie. def clear_autologin_cookie cookies.delete(:mo_user) end # Store User in session (id only). def set_session_user(user) session[:user_id] = user ? user.id : nil return user end # Retrieve the User from session. Returns User object or nil. (Does not # check verified status or anything.) def get_session_user result = nil if id = session[:user_id] result = User.find(id) rescue nil end result end ############################################################################## # # :section: Internationalization # ############################################################################## # Get sorted list of locale codes (String's) that we have translations for. def all_locales Dir.glob(RAILS_ROOT + '/lang/ui/*.yml').sort.map do |file| file.sub(/.*?(\w+-\w+).yml/, '\\1') end end helper_method :all_locales # Translate the given pulldown menu. Accepts and returns the same structure # the select menu helper takes: # # <% # menu = [ # [ :label1, value1 ], # [ :label2, value2 ], # ... # ] # select('object', 'field', translate_menu(menu), options => ...) # %> # # (Just calls +l+ on each label.) (*NOTE*: this is available to views.) # def translate_menu(menu) result = [] for k,v in menu result << [ (k.is_a?(Symbol) ? k.l : k.to_s), v ] end return result end helper_method :translate_menu # Before filter: Decide which locale to use for this request. Sets the # Globalite default. Tries to get the locale from: # # 1. parameters (user clicked on language in bottom left) # 2. user prefs (user edited their preferences) # 3. session (whatever we used last time) # 4. navigator (provides default) # 5. server (DEFAULT_LOCALE) # def set_locale code = if params[:user_locale] logger.debug "[globalite] loading locale: #{params[:user_locale]} from params" params[:user_locale] elsif @user && !@user.locale.blank? logger.debug "[globalite] loading locale: #{@user.locale} from @user" @user.locale elsif session[:locale] logger.debug "[globalite] loading locale: #{session[:locale]} from session" session[:locale] elsif locale = get_valid_locale_from_request_header logger.debug "[globalite] loading locale: #{locale} from request header" locale else DEFAULT_LOCALE end # Only change the Locale code if it needs changing. There is about a 0.14 # second performance hit every time we change it... even if we're only # changing it to what it already is!! code = standardize_locale(code) if Locale.code.to_s != code Locale.code = code session[:locale] = code end # One last sanity check. (All translation YML files should have :en_US # defined.) if :en_US.l != 'English' logger.warn("No translation exists for: #{Locale.code}") Locale.code = DEFAULT_LOCALE end # Update user preference. if @user && @user.locale.to_s != Locale.code.to_s @user.update_attributes(:locale => Locale.code.to_s) Transaction.put_user(:id => @user, :set_locale => Locale.code.to_s) end logger.debug "[globalite] Locale set to #{Locale.code}" # Tell Rails to continue to process request. return true end # Return Array of the browser's requested locales (HTTP_ACCEPT_LANGUAGE). # Example syntax: # # en-au,en-gb;q=0.8,en;q=0.5,ja;q=0.3 # def get_sorted_locales_from_request_header result = [] if accepted_locales = request.env['HTTP_ACCEPT_LANGUAGE'] # Extract locales and weights, creating map from locale to weight. locale_weights = {} accepted_locales.split(',').each do |term| if (term + ';q=1') =~ /^(.+?);q=([^;]+)/ locale_weights[$1] = ($2.to_f rescue -1.0) end end # Now sort by decreasing weights. result = locale_weights.sort {|a,b| b[1] <=> a[1]}.map {|a| a[0]} end logger.debug "[globalite] client accepted locales: #{result.join(', ')}" return result end # Returns our locale that best suits the HTTP_ACCEPT_LANGUAGE request header. # Returns a String, or nil if no valid match found. def get_valid_locale_from_request_header # Get list of languages browser requested, sorted in the order it prefers # them. (And convert them to standardized format: 'en' or 'en-US'.) requested_locales = get_sorted_locales_from_request_header.map do |locale| if locale.match(/^(\w\w)-(\w+)$/) locale = "#{$1.downcase}-#{$2.upcase}" else locale = locale.downcase end end # Lookup the closest match based on the given request priorities. lookup_valid_locale(requested_locales) end # Returns our locale that best suits the HTTP_ACCEPT_LANGUAGE request header. # Returns a String, or nil if no valid match found. def lookup_valid_locale(requested_locales) match = nil # Get list of available locales. available_locales = Globalite.ui_locales.values.map(&:to_s).sort # Look for matches. fallback = nil requested_locales.each do |locale| logger.debug "[globalite] trying to match locale: #{locale}" # What is the "preferred" dialect for this language? Default is 'xx-XX'. locale2 = { 'en' => 'en-US' }[locale[0,2]] || "#{locale[0,2]}-#{locale[0,2].upcase}" # User requested "xx-YY" and we have it. if available_locales.include?(locale) match = locale logger.debug "[globalite] exact match: #{match}" # Check for "preferred" dialect 'xx-XX' first. elsif available_locales.include?(locale2) if locale.length > 2 # User requestsed "xx-YY", we have "xx-XX". fallback ||= locale2 else # User requested "xx", we have "xx-XX". match = locale2 logger.debug "[globalite] default language-match: #{match}" end # Now we try for any other 'xx-YY'. else available_locales.each do |locale2| if locale2[0,2] == locale[0,2] if locale.length > 2 # User requestsed "xx-YY", we have "xx-ZZ". fallback ||= locale2 else # User requested "xx", we have "xx-YY". match = locale2 logger.debug "[globalite] other language-match: #{match}" end end end end break if match end # Fallback can be set if the user requested only exact locales. If none # of their exact locales worked, we give them default language-matches (in # the same order) instead. Example: # # request = en-AU,pt-PT # match = -- (no matches) # fallback = en-US (but "en" would have matched) # # We have neither en-AU nor pt-PT, but we do have en-US and pt-BR. We give # them en-US because en-XX comes before pt-XX in their request. Normally # they would request something like this instead, of course: # # request = en-AU,en,pt-PT,pt # match = en-US (both "en" and "pt" match) # match || fallback end # Standardize locale code to the format Globalite uses: 'xx-YY'. Returns a # String or raises a RuntimeError if it's invalid. (*NOTE*: Globalite's # +Locale.code+ is a symbol!) # # en_us -> en-US # en-us -> en-US # en_US -> en-US # en-US -> en-US # en -> (error) # en-* -> (error) # e-US -> (error) # eng-US -> (error) # def standardize_locale(code) if code.to_s.match(/^([a-z][a-z])[_\-](\w+)/i) $1.downcase + '-' + $2.upcase elsif code2 = lookup_valid_locale([code]) code2 elsif PRODUCTION DEFAULT_LOCALE else raise("Invalid locale: #{code.inspect}") end end ############################################################################## # # :section: Error handling # # This is somewhat non-intuitive, so it's worth describing exactly what # happens. There are two fundamentally different cases: # # 1. Request is rendered successfully (200). # # Errors that occur while processing the action are added to # session[:notice]. They are rendered in the layout, then cleared. # If they weren't cleared, they would carry through to the next action (via # +flash+ mechanism) and get rendered twice (or more!). # # 2. Request is redirected (302). # # Errors that occur while processing the action are added to # session[:notice] as before. Browser is redirected. This may # happen multiple times before an action finally renders a template. Once # this finally happens, all the errors that have accumulated in # session[:notice] are displayed, then cleared. # # *NOTE*: I just noticed that we've been incorrectly using the +flash+ # mechanism for this all along. This can fail if you flash an error, # redirect, then redirect again without rendering any additional error. # If you don't change a flash field it automatically gets cleared. # ############################################################################## # Are there any errors pending? Returns true or false. def flash_notices? !session[:notice].nil? end helper_method :flash_notices? # Get a copy of the errors. Return as String. def flash_get_notices session[:notice].to_s[1..-1] end helper_method :flash_get_notices # Get current notice level. (0 = notice, 1 = warning, 2 = error) def flash_notice_level level = session[:notice].to_s[0,1] level == '' ? nil : level.to_i end helper_method :flash_notice_level # Clear error/warning messages. *NOTE*: This is done automatically by the # application layout (app/views/layouts/application.rhtml) every time it # renders the latest error messages. def flash_clear if TESTING flash[:rendered_notice] = session[:notice] end session[:notice] = nil end helper_method :flash_clear # Report an informational message that will be displayed (in green) at the # top of the next page the User sees. def flash_notice(str) session[:notice] += '
' if session[:notice] session[:notice] ||= '0' session[:notice] += str.to_s end helper_method :flash_notice # Report a warning message that will be displayed (in yellow) at the top of # the next page the User sees. def flash_warning(str) flash_notice(str) session[:notice][0,1] = '1' if session[:notice][0,1] == '0' end helper_method :flash_warning # Report an error message that will be displayed (in red) at the top of the # next page the User sees. def flash_error(str) flash_notice(str) session[:notice][0,1] = '2' if session[:notice][0,1] != '2' end helper_method :flash_error # Report the errors for a given ActiveRecord::Base instance. These will be # displayed (in red) at the top of the next page the User sees. # # if object.save # flash_notice "Yay!" # else # flash_error "Failed to save changes." # flash_object_error(object) # end # def flash_object_errors(obj) if obj && obj.errors && (obj.errors.length > 0) flash_error(obj.formatted_errors.join("
")) end end ############################################################################## # # :section: Name validation # ############################################################################## # Do some simple queries to try to find alternate spellings of the given # (incorrectly-spelled) name. def guess_correct_name(name) results = [] # Do some really basic pre-parsing, stripping off author and spuh. name = name.gsub('_',' ').strip_squeeze.capitalize_first name = name.sub(/ sp\.?$/, '') name = Name.parse_author(name).first # (strip author off) # Guess genus first, then species, and so on. if name words = name.split num = words.length results = guess_correct_word('', words.first) for i in 2..num if results.any? if (i & 1) == 0 prefixes = results.map(&:text_name).uniq results = [] word = (i == 2) ? words[i-1] : "#{words[i-2]} #{words[i-1]}" for prefix in prefixes results |= guess_correct_word(prefix, word) end end end end end return results end # Guess correct name of partial string. def guess_correct_word(prefix, word) # :nodoc: str = "#{prefix} #{word}" results = guess_correct_try(str, 1) results = guess_correct_try(str, 2) results = guess_correct_try(str, 3) if results.empty? return results end # Look up name replacing n letters at a time with a star. def guess_correct_try(name, n) # :nodoc: patterns = [] # Restrict search to names close in length. a = name.length - 2 b = name.length + 2 # Create a bunch of SQL "like" patterns. name = name.gsub(/ \w+\. /, ' % ') words = name.split for i in 0..(words.length-1) word = words[i] if word != '%' if word.length < n patterns << guess_correct_pattern(words, i, '%') else for j in 0..(word.length-n) sub = '' sub += word[0..(j-1)] if j > 0 sub += '%' sub += word[(j+n)..(-1)] if j + n < word.length patterns << guess_correct_pattern(words, i, sub) end end end end # Create SQL query out of these patterns. conds = patterns.map do |pat| "text_name LIKE '#{pat}'" end.join(' OR ') conds = "(LENGTH(text_name) BETWEEN #{a} AND #{b}) AND (#{conds})" names = Name.all(:conditions => conds, :limit => 10) # Screen out ones way too different. names = names.reject do |x| (x.text_name.length < a) or (x.text_name.length > b) end return names end # String words together replacing the one at index +i+ with +sub+. def guess_correct_pattern(words, i, sub) # :nodoc: result = [] for j in 0..(words.length-1) result << (i == j ? sub : words[j]) end return result.join(' ') end # This is called by +create_name_helper+ (used by +create_observation+, # +create_naming+, and +edit_naming+) and +deprecate_name+. It creates a new # name, first checking if it is a valid name, and that it has been approved # by the user. Uses Name.names_from_string(@what) to do the # parsing. # # input_what:: params[:approved_name] (name that user typed before # getting the "this name not recognized" message) # output_what:: @what (name after "this name not recognized" message, # must be the same or it is not "approved") # # Returns +nil+ if user hasn't approved the name. If approved, it creates # and returns a new Name record (saved). # def create_needed_names(input_what, output_what) result = nil if input_what == output_what # This returns an array of Names: genus, species, then variety (if # applicable). New names are created for any that don't exist... but # they need to be saved if they are new (just check if any is missing # an id). names = Name.names_from_string(output_what) if names.last.nil? flash_error :runtime_no_create_name.t(:type => :name, :value => output_what) else for n in names save_name(n, :log_updated_by) if n end end result = names.last end result end # Goes through list of names entered by user and creates (and saves) any that # are not in the database (but only if user has approved them). # # Used by: bulk_name_editor, change_synonyms, create/edit_species_list # # Inputs: # # name_list string, delimted by newlines (see below for syntax) # approved_names array of search_names (or string delimited by "/") # deprecate? are any created names to be deprecated? # # Syntax: (NameParse class does the actual parsing) # # Xxx yyy # Xxx yyy var. zzz # Xxx yyy Author # Xxx yyy sensu Blah # Valid name Author = Deprecated name Author # blah blah [comment] # (this is described better in views/observer/bulk_name_edit.rhtml) # def construct_approved_names(name_list, approved_names, deprecate=false) if approved_names if approved_names.is_a?(String) approved_names = approved_names.split(/\r?\n/) end for ns in name_list if !ns.blank? name_parse = NameParse.new(ns) construct_approved_name(name_parse, approved_names, deprecate) end end end end # Processes a single line from the list above. # Used only by construct_approved_names(). def construct_approved_name(name_parse, approved_names, deprecate) # Don't do anything if the given names are not approved if approved_names.member?(name_parse.name) # Create name object for this name (and any parents, such as genus). names = Name.names_from_string(name_parse.search_name) # Parse must have failed. if names.last.nil? flash_error :runtime_no_create_name.t(:type => :name, :value => name_parse.name) # Was successful. else name = names.last name.rank = name_parse.rank if name_parse.rank # Process comments (for bulk name editor). if comment = name_parse.comment # Okay to add citation to any record without an existing citation. if comment.match(/^citation: *(.*)/) citation = $1 name.citation = citation if name.citation.blank? # Only save comment if name didn't exist elsif names.new_record? name.notes = comment else flash_warning("Didn't save comment for #{name.search_name}, " + "name already exists. (comment = \"#{comment}\")") end end # Only bulk name editor allows the synonym syntax now. Tell it to # approve the left-hand name. deprecate2 = deprecate deprecate2 = false if name_parse.has_synonym # Save the names (deals with deprecation here). save_names(names, deprecate2) end end # Do the same thing for synonym (found the Approved = Synonym syntax). if name_parse.has_synonym and approved_names.member?(name_parse.synonym) # Create the synonym. synonyms = Name.names_from_string(name_parse.synonym_search_name) # Parse must have failed. if synonyms.last.nil? flash_error :runtime_no_create_name.t(:type => :name, :value => name_parse.synonym) # Was successful. else synonym = synonyms.last synonym.rank = name_parse.synonym_rank if name_parse.synonym_rank # Process comments (for bulk name editor). if comment = name_parse.synonym_comment # Only save comment if name didn't exist if synonym.new_record? synonym.notes = comment else flash_warning("Didn't save comment for #{synonym.search_name}, " + "name already exists. (comment = \"#{comment}\")") end end # Deprecate and save. synonym.change_deprecated(true) save_name(synonym, :log_deprecated_by, :touch => true) save_names(synonyms[0..-2], nil) # Don't change higher taxa end end end # Makes sure an array of names are saved, deprecating them if you wish. # Inputs: # names array of name objects (unsaved) # deprecate create them deprecated to start with def save_names(names, deprecate) log = nil unless deprecate.nil? if deprecate log = :log_deprecated_by else log = :log_approved_by end end for n in names if n # Could be nil if parent is ambiguous with respect to the author n.change_deprecated(deprecate) if deprecate && n.new_record? save_name(n, log) end end end # Save any changes to this name (including creating it if it is a new # record), log the change, add the current user as the editor, and log the # transaction appropriately for syncing with foreign databases. def save_name(name, log=nil, args={}) log ||= :log_name_updated # Get list of args we care about. (intersection) changed_args = name.changed & [ :rank, :text_name, :author, :citation, :synonym, :deprecated, :correct_spelling, :notes ] # Log transaction. xargs = { :id => name } if name.new_record? for arg in changed_args xargs[arg] = name.send(arg) end xargs[:method] = :post else for arg in changed_args xargs[:"set_#{arg}"] = name.send(arg) end xargs[:method] = :put end # Save any changes. if name.changed? args = { :touch => name.altered? }.merge(args) name.log(log, args) if name.save Transaction.create(xargs) result = true else flash_object_errors(name) result = false end end return result end ############################################################################## # # :section: Searching # # The general idea is that the user executes a search or requests an index, # then clicks on a result. This takes the user to a show_object page. This # page "knows" about the search or index via a special universal URL # parameter (via +query_params+). When the user then clicks on "prev" or # "next", it can then step through the query results. # # While browsing like this, the user may want to divert temporarily to add a # comment or propose a name or something. These actions are responsible for # keeping track of these search parameters, and eventually passing them back # to the show_object page. Usually they just pass the query parameter # through via +pass_query_params+. # # See Query and AbstractQuery for more detail. # ############################################################################## # This clears the search/index saved in the session. def clear_query_in_session session[:checklist_source] = nil end # This stores the latest search/index used for use by create_species_list. # (Stores the Query id in session[:checklist_source].) def store_query_in_session(query) query.save if !query.id session[:checklist_source] = query.id end # Get Query last stored on the "clipboard" (session). def get_query_from_session if id = session[:checklist_source] Query.safe_find(id) else nil end end # Return query parameter(s) necessary to pass query information along to # the next request. *NOTE*: This method is available to views. def query_params(query=nil) if query query.save if !query.id {:q => query.id.alphabetize} else @query_params || {} end end helper_method :query_params # Pass the in-coming query parameter(s) through to the next request. def pass_query_params @query_params = {} @query_params[:q] = params[:q] if !params[:q].blank? @query_params end # Change the query that +query_params+ passes along to the next request. # *NOTE*: This method is available to views. def set_query_params(query=nil) @query_params = {} if query query.save if !query.id @query_params[:q] = query.id.alphabetize end @query_params end helper_method :set_query_params # Lookup an appropriate Query or create a default one if necessary. If you # pass in arguments, it modifies the query as necessary to ensure they are # correct. (Useful for specifying sort conditions, for example.) def find_or_create_query(model, args={}) model = model.to_s if result = find_query(model, false) # Check if the existing query needs to be modified. any_changes = false for arg, val in args if result.params[:arg] != val any_changes = true break end end # If it does, we need to create a new query, otherwise the modifications # won't persist. Use the existing query as the template, though. if any_changes result = create_query(model, result.flavor, result.params.merge(args)) end # Otherwise, just create a default one. else result = create_query(model, :default, args) end if result && !is_robot? result.access_count += 1 result.save end return result end # Lookup the given kind of Query, returning nil if it no longer exists. def find_query(model=nil, update=!is_robot?) model = model.to_s if model result = nil q = params[:q].dealphabetize rescue nil if q && (query = Query.safe_find(q)) # This is right kind of query. if !model or (query.model_string == model) result = query # If not, try coercing it. elsif query2 = query.coerce(model) result = query2 # If that fails, try the outer query coercing if necessary. elsif query = query.outer if query.model_string == model result = query elsif query2 = query.coerce(model) result = query2 end end if update && result result.access_count += 1 result.save end end return result end # Create a new Query of the given flavor for the given model. Pass it # in all the args you would to Query#new. *NOTE*: Not all flavors are # capable of supplying defaults for every argument. def create_query(model, flavor=:default, args={}) Query.lookup(model, flavor, args) end # This is the common code for all the 'prev/next_object' actions. Pass in # the current object and direction (:prev or :next), and it looks up the # query, grabs the next object, and redirects to the appropriate # 'show_object' action. # # def next_image # redirect_to_next_object(:next, Image, params[:id]) # end # def redirect_to_next_object(method, model, id) if object = find_or_goto_index(model, id) # Special exception for prev/next in RssLog query: If go to "next" in # show_observation, for example, inside an RssLog query, go to the next # object, even if it's not an observation. If... if params[:q] and # ... query parameter given (q = params[:q].dealphabetize rescue nil) and (query = Query.safe_find(q)) and # ... and query exists (query.model_symbol == :RssLog) and # ... and it's a RssLog query (rss_log = object.rss_log rescue nil) and # ... and current rss_log exists query.index(rss_log) and # ... and it's in query results (query.current = object.rss_log) and # ... and can set current index in query results (new_query = query.send(method)) and # ... and next/prev doesn't return nil (at end) (rss_log = new_query.current) # ... and can get new rss_log object query = new_query object = rss_log.target || rss_log id = object.id # Normal case: attempt to coerce the current query into an appropriate # type, and go from there. This handles all the exceptional cases: # 1) query not coercable (creates a new default one) # 2) current object missing from results of the current query # 3) no more objects being left in the query in the given direction else query = find_or_create_query(object.class) query.current = object if !query.index(object) type = object.type_tag flash_error(:runtime_object_not_in_index.t(:id => object.id, :type => type)) elsif new_query = query.send(method) query = new_query id = query.current_id else type = object.type_tag flash_error(:runtime_no_more_search_objects.t(:type => type)) end end # Redirect to the show_object page appropriate for the new object. redirect_to(:controller => object.show_controller, :action => object.show_action, :id => id, :params => query_params(query)) end end ############################################################################## # # :section: Indexes # ############################################################################## # Render an index or set of search results as a list or matrix. Arguments: # query:: Query instance describing search/index. # args:: Hash of options. # # Options include these: # id:: Warp to page that includes object with this id. # action:: Template used to render results. # matrix:: Displaying results as matrix? # letters:: Paginating by letter? # letter_arg:: Param used to store letter for pagination. # number_arg:: Param used to store page number for pagination. # num_per_page:: Number of results per page. # sorting_links:: Array of pairs: ["by" String, label String] # always_index:: Always show index, even if only one result. # link_all_sorts:: Don't gray-out the current sort criteria. # # Side-effects: (sets/uses the following instance variables for the view) # @title:: Provides default title. # @links:: # @sorts:: # @layout:: # @pages:: Paginator instance. # @objects:: Array of objects to be shown. # @extra_data:: Results of block yielded on every object if block given. # # Other side-effects: # store_location:: Sets this as the +redirect_back_or_default+ location. # clear_query_in_session:: Clears the query from the "clipboard" (if you didn't just store this query on it!). # set_query_params:: Tells +query_params+ to pass this query on in links on this page. # def show_index_of_objects(query, args={}) letter_arg = args[:letter_arg] || :letter number_arg = args[:number_arg] || :page num_per_page = args[:num_per_page] || 50 include = args[:include] || nil type = query.model.type_tag # Tell site to come back here on +redirect_back_or_default+. store_location # Clear out old query from session. (Don't do it if caller just finished # storing *this* query in there, though!!) if session[:checklist_source] != query.id clear_query_in_session end # Pass this query on when clicking on results. set_query_params(query) # Supply a default title. @title ||= query.title # Supply default error message to display if no results found. if (query.params.keys - query.required_parameters - [:by]).empty? @error ||= case query.flavor when :all :runtime_no_objects.t(:type => type) when :at_location loc = query.find_cached_parameter_instance(Location, :location) :runtime_index_no_at_location.t(:type => type, :location => loc.display_name) when :at_where :runtime_index_no_at_location.t(:type => type, :location => query.params[:location]) when :by_author user = query.find_cached_parameter_instance(User, :user) :runtime_user_hasnt_authored.t(:type => type, :user => user.legal_name) when :by_editor user = query.find_cached_parameter_instance(User, :user) :runtime_user_hasnt_edited.t(:type => type, :user => user.legal_name) when :by_rss_log :runtime_index_no_by_rss_log.t(:type => type) when :by_user user = query.find_cached_parameter_instance(User, :user) :runtime_user_hasnt_created.t(:type => type, :user => user.legal_name) when :for_target :runtime_index_no_for_object.t(:type => type) when :for_user user = query.find_cached_parameter_instance(User, :user) :runtime_index_no_for_user.t(:type => type, :user => user.legal_name) when :in_species_list spl = query.find_cached_parameter_instance(SpeciesList, :species_list) :runtime_index_no_in_species_list.t(:type => type, :name => spl.title) when :inside_observation id = query.params[:observation] :runtime_index_no_inside_observation.t(:type => type, :id => id) when :of_children name = query.find_cached_parameter_instance(Name, :name) :runtime_index_no_of_children.t(:type => type, :name => name.display_name) when :of_name name = query.find_cached_parameter_instance(Name, :name) :runtime_index_no_of_name.t(:type => type, :name => name.display_name) when :of_parents name = query.find_cached_parameter_instance(Name, :name) :runtime_index_no_of_parents.t(:type => type, :name => name.display_name) when :pattern_search :runtime_no_matches_pattern.t(:type => type, :value => query.params[:pattern].to_s) when :with_descriptions :runtime_index_no_with.t(:type => type, :attachment => :description) when :with_observations :runtime_index_no_with.t(:type => type, :attachment => :observation) end end @error ||= :runtime_no_matches.t(:type => type) # Add magic links for sorting. if (sorts = args[:sorting_links]) and (sorts.length > 1) @sorts = add_sorting_links(query, sorts, args[:link_all_sorts]) else @sorts = nil end # Get user prefs for displaying results as a matrix. if args[:matrix] @layout = calc_layout_params num_per_page = @layout['count'] end # Inform the query that we'll need the first letters as well as ids. if args[:letters] query.need_letters = args[:letters] end # Time query -- this caches the ids (and first letters if needed). logger.warn("QUERY starting: #{query.query.inspect}") @timer_start = Time.now @num_results = query.num_results @timer_end = Time.now logger.warn("QUERY finished: model=#{query.model_string}, " + "flavor=#{query.flavor}, params=#{query.params.inspect}, " + "time=#{(@timer_end-@timer_start).to_f}") # If only one result (before pagination), redirect to 'show' action. if (query.num_results == 1) and !args[:always_index] redirect_to(:controller => query.model.show_controller, :action => query.model.show_action, :id => query.result_ids.first, :params => query_params) # Otherwise paginate results. (Everything we need should be cached now.) else @pages = if args[:letters] paginate_letters(letter_arg, number_arg, num_per_page) else paginate_numbers(number_arg, num_per_page) end # Skip to correct place if coming back in to index from show_object. if !args[:id].blank? and params[@pages.letter_arg].blank? and params[@pages.number_arg].blank? @pages.show_index(query.index(args[:id])) end # Instantiate correct subset. @objects = query.paginate(@pages, :include => include) # Give the caller the opportunity to add extra columns. if block_given? @extra_data = @objects.inject({}) do |data,object| row = yield(object) row = [row] if !row.is_a?(Array) data[object.id] = row data end end # Render the list if given template. render(:action => args[:action]) if args[:action] end end # Create sorting links for index pages, "graying-out" the current order. def add_sorting_links(query, links, link_all=false) results = [] this_by = query.params[:by] || query.default_order this_by = this_by.to_s.sub(/^reverse_/, '') for by, label in links str = label.t if !link_all and (by.to_s == this_by) results << str else results << [str, { :controller => query.model.show_controller, :action => query.model.index_action, :by => by, :params => query_params }] end end # Add a "reverse" button. str = :sort_by_reverse.t if query.params[:by].to_s.match(/^reverse_/) reverse_by = this_by else reverse_by = "reverse_#{this_by}" end results << [str, { :controller => query.model.show_controller, :action => query.model.index_action, :by => reverse_by, :params => query_params }] return results end # Lookup a given object, displaying a warm-fuzzy error and redirecting to the # appropriate index if it no longer exists. def find_or_goto_index(model, id, *args) result = model.safe_find(id, *args) if !result flash_error(:runtime_object_not_found.t(:id => id || '0', :type => model.type_tag)) redirect_to(:controller => model.show_controller, :action => model.index_action, :params => query_params) end return result end # Redirects to an appropriate fallback index in case of unrecoverable error. # Most such errors are dealt with on a case-by-case basis in the controllers, # however a few generic actions don't necessarily know where to send users # when things go south. This makes a good stab at guessing, at least. def goto_index(redirect=nil) pass_query_params redirect = redirect.name.underscore if redirect.is_a?(Class) model = case (redirect || controller.name).to_s when 'account' ; RssLog when 'comment' ; Comment when 'image' ; Image when 'location' ; Location when 'name' ; Name when 'naming' ; Observation when 'observation' ; Observation when 'observer' ; RssLog when 'project' ; Project when 'rss_log' ; RssLog when 'species_list' ; SpeciesList when 'user' ; RssLog when 'vote' ; Observation end raise "Not sure where to go from #{redirect || controller.name}." if !model redirect_to(:controller => model.show_controller, :action => model.index_action, :params => query_params) end # Initialize Paginator object. This now does very little thanks to the new # Query model. # arg:: Name of parameter to use. (default is 'letter') # # # In controller: # query = create_query(:Name, :by_user, :user => params[:id]) # query.need_letters('names.observation_name') # @pages = paginate_letters(:letter, :page, 50) # @names = query.paginate(@pages) # # # In view: # <%= pagination_letters(@pages) %> # <%= pagination_numbers(@pages) %> # def paginate_letters(letter_arg=:letter, number_arg=:page, num_per_page=50) MOPaginator.new( :letter_arg => letter_arg, :number_arg => number_arg, :letter => (params[letter_arg].to_s.match(/^([A-Z])$/i) ? $1.upcase : nil), :number => (params[number_arg].to_s.to_i rescue 1), :num_per_page => num_per_page ) end # Initialize Paginator object. This now does very little thanks to # the new Query model. # arg:: Name of parameter to use. (default is 'page') # num_per_page:: Number of results per page. (default is 50) # # # In controller: # query = create_query(:Name, :by_user, :user => params[:id]) # @numbers = paginate_numbers(:page, 50) # @names = query.paginate(@numbers) # # # In view: # <%= pagination_numbers(@numbers) %> # def paginate_numbers(arg=:page, num_per_page=50) MOPaginator.new( :number_arg => arg, :number => (params[arg].to_s.to_i rescue 1), :num_per_page => num_per_page ) end ############################################################################## # # :section: Memory usage. # ############################################################################## def count_objects ObjectSpace.each_object do |o| end end def extra_gc ObjectSpace.garbage_collect end def log_memory_usage sd = sc = pd = pc = 0 File.new("/proc/#{$$}/smaps").each_line do |line| if line.match(/\d+/) val = $&.to_i line.match(/^Shared_Dirty/) ? (sd += val) : line.match(/^Shared_Clean/) ? (sc += val) : line.match(/^Private_Dirty/) ? (pd += val) : line.match(/^Private_Clean/) ? (pc += val) : 1 end end uid = session[:user_id].to_i logger.warn "Memory Usage: pd=%d, pc=%d, sd=%d, sc=%d (pid=%d, uid=%d, uri=%s)\n" % \ [pd, pc, sd, sc, $$, uid, request.request_uri] end ################################################################################ # # :section: Other stuff # ################################################################################ # Before filter: disable link prefetching. # # This, I'm inferring, is when an over-achieving browser actively goes out # prefetching all the pages linked to from the current page so that the user # doesn't have to wait as long when they click on one. The problem is, if # the browser pre-fetches something like +destroy_comment+, it could # potentially delete or otherwise harm things unintentionally. # # The old policy was to disable this feature for a few obviously dangerous # actions. I've changed it now to only _enable_ it for common (and safe) # actions like show_observation, post_comment, etc. Each controller is now # responsible for explicitly listing the actions which accept it. # -JPH 20100123 # def disable_link_prefetching if request.env["HTTP_X_MOZ"] == "prefetch" logger.debug "prefetch detected: sending 403 Forbidden" render(:text => '', :status => 403) return false end end # Tell an object that someone has looked at it (unless a robot made the # request). def update_view_stats(object) if object.respond_to?(:update_view_stats) && !is_robot? object.update_view_stats end end # Default image size to use for thumbnails: either :thumbnail or :small. # Looks at both the user's pref (if logged in) or the session (if not logged # in), else reverts to small. *NOTE*: This method is available to views. def default_thumbnail_size if @user @user.thumbnail_size else session[:thumbnail_size] || :thumbnail end end helper_method :default_thumbnail_size # Set the default thumbnail size, either for the current user if logged in, # or for the current session. def set_default_thumbnail_size(val) if @user if @user.thumbnail_size != val @user.thumbnail_size = val @user.save_without_our_callbacks end else session[:thumbnail_size] = val end end # Get User's list layout preferences, providing defaults as necessary. # Returns a hash of options. (Uses the current user from +@user+.) # # opts = calc_layout_params # # opts["rows"] # Number of rows to display. # opts["columns"] # Number of columns to display. # opts["alternate_rows"] # Alternate colors for rows. # opts["alternate_columns"] # Alternate colors for columns. # opts["vertical_layout"] # Stick text below thumbnail? # opts["count"] # Total number of items = rows * columns. # def calc_layout_params result = {} result["rows"] = 5 result["columns"] = 3 result["alternate_rows"] = true result["alternate_columns"] = true result["vertical_layout"] = true if @user result["rows"] = @user.rows if @user.rows result["columns"] = @user.columns if @user.columns result["alternate_rows"] = @user.alternate_rows result["alternate_columns"] = @user.alternate_columns result["vertical_layout"] = @user.vertical_layout end result["count"] = result["rows"] * result["columns"] result end end