# encoding: utf-8 # # = User Model # # Model describing a User. # # Login is handled by lib/login_system.rb, a third-party package that we've # modified slightly. It is enforced by adding before_filter # :login_required filters to the controllers. # # We now support autologin or "remember me" login via a simple cookie and the # application-wide before_filter :autologin filter in # ApplicationController. # # == Signup / Login Process # # Only part of this site is available to anonymous guests. Sign-up is a # two-step process, requiring email verification before the new User can log # in. The full process is as follows: # # 1. Anonymous User pokes around until they try to post a Comment, say. This # page requires a login (via +login_required+ filter in controller, see # below). This causes the User to be redirected to /account/login. # # 2. If the User already has an account, they login here, and wind up # redirected back to the form that triggered the login. # # 3. If the User has no account, they click on "Create a new account" and go # to /account/signup. They fill out the form and submit it. This # creates a new User record, but this record is still unverified (verified # is +nil+). # # 4. A verification email is sent to the email address given in the sign-up # form. Inside the email is a link to /account/verify. This provides the # User +id+ and +auth_code+. # # 5. When they click on that link, the User record is updated and the User is # automatically logged in. # # == ApplicationController Filters # # The execution flow for an HTTP request as affects login, including all # application-wide filters, is as follows: # # 1. +browser_status+: Determine browser type and state of javascript. # # 2. +autologin+: Check if User is logged in by first looking at session, then # autologin cookie. Requires User be verified. Stores User in session, # cookie, User#current, and +@user+ (visible to controllers and views). # Sets all these to nil if no User logged in. # # 3. +check_user_alert+: Check if User has an alert to show, redirecting if so. # # 4. +set_locale+: Check if User has chosen a locale. # # 5. +login_required+: (optional) Redirects to /account/login if not # logged in. # # == Contribution Score # # Contribution score is just a cache. It is very carefully kept up to date by # several callbacks in Model and a few other Model subclasses that modify the # score every time a User creates, edits or destroys an object. It is also # automatically refreshed whenever anyone views the User's summary page, just # in case the callbacks ever fail. # # == Alerts # # Admins can create an alert for a User. These are messages that they will # see the very next time they try to load a page. Only one is allowed at a # time for the User right now. All the information about the alert is stored # in a simple hash which is stored, serialized, as a +text+ column in the # database. When the User sees the message, they have three options: # # 1. Acknowledge the alert by clicking on "okay", and the alert is deleted. # 2. Tell it to display the message again in a day (see +alert_next_showing+). # 3. Exit or navigate away without acknowledging it, causing the alert to be # shown over and over until they get tired and say "okay". # # == Admin Mode # # Any User can be granted administrator privileges. However, we don't want # admins wandering around the site in "admin mode" during every-day usage. # Thus we additionally require that admin User's also turn on admin mode. # (There's a handy switch in the left-hand column of every page.) This state # is stored in the session. (See ApplicationController#is_in_admin_mode?) # # == Attributes # # id:: Locally unique numerical id, starting at 1. # sync_id:: Globally unique alphanumeric id, used to sync with remote servers. # created:: Date/time it was first created. # modified:: Date/time it was last modified. # verified:: Date/time the account was verified. # last_login:: Date/time the user last logged in. # # ==== Administrative # login:: Login name (must be locally unique). # name:: Full name. # password:: Hashed password. # email:: Email address. # admin:: Allowed to enter admin mode? # created_here:: Was this account originally created on this server? # alert:: Alert message we need to display for User. (serialized) # bonuses:: List of zero or more contribution bonuses. (serialized) # contribution:: Contribution score (integer). # # ==== Profile # mailing_address:: Mailing address used in naming_for_observer emails. # notes:: Free-form Textile notes (provided by User). # location:: Primary location (chosen by User). # image:: Mug-shot Image. # license:: Default license for Images this User uploads. # # ==== Preferences # locale:: Language, e.g.: "en-US" or "pt-BR" # theme:: CSS theme, e.g.: "Amanita" or +nil+ for random # rows:: Number of rows of thumbnails to show in index. # columns:: Number of columns of thumbnails to show in index. # alternate_rows:: Should the row colors alternate? # alternate_columns:: Should the column colors alternate? # vertical_layout:: Show text below thumbnails in index? # # ==== Email options # Send notifications if... # email_comments_owner:: ...someone comments on object I own. # email_comments_response:: ...someone responds to my Comment. # email_comments_all:: ...anyone comments on anything. # email_observations_consensus:: ...consensus changes on my Observation. # email_observations_naming:: ...someone proposes a Name for my Observation. # email_observations_all:: ...anyone changes an Observation. # email_names_author:: ...someone changes a Name I've authored. # email_names_editor:: ...someone changes a Name I've edited. # email_names_reviewer:: ...someone changes a Name I've reviewed. # email_names_all:: ...anyone changes a Name. # email_locations_author:: ...someone changes a Location I've authored. # email_locations_editor:: ...someone changes a Location I've edited. # email_locations_all:: ...anyone changes a Location. # email_general_feature:: ...you announce new features. # email_general_commercial:: ...someone sends me a commercial inquiry. # email_general_question:: ...someone sends me a general question. # email_digest:: (not used yet) # email_html:: Send HTML-formatted email? # # ==== "Fake" attributes # place_name:: Allows User to enter location by name. # password_confirmation:: Used to confirm password during sign-up. # # == Methods # # current:: Report the User that is currently logged in. # current_id:: Report the User (id) that is currently logged in. # # ==== Names # text_name:: User name as: "loging" (for debugging) # legal_name:: User name as: "First Last" or "login" # unique_text_name:: User name as: "First Last (login)" or "login" # # ==== Authentication # authenticate:: Verify login + password. # auth_code:: Code used to verify autologin cookie and POSTs in API. # change_password:: Change password (on an existing record). # # ==== Interests # interest_in:: Return state of User's interest in a given object. # watching?:: Is User watching a given object? # ignoring?:: Is User ignoring a given object? # # ==== Profile # percent_complete:: How much of profile has User finished? # sum_bonuses:: Add up all the bonuses User has earned. # # ==== Object ownership # comments:: Comment's they've posted. # images:: Image's they've uploaded. # interests:: Interest's they've indicated. # locations:: Location's they were last to edit. # names:: Name's they were last to edit. # namings:: Naming's they've proposed. # notifications:: Notification's they've requested. # observations:: Observation's they've posted. # projects_created:: Project's they've created. # queued_emails:: QueuedEmail's they're scheduled to receive. # species_lists:: SpeciesList's they've created. # votes:: Vote's they've cast. # # ==== Other relationships # to_emails:: QueuedEmail's they've caused to be sent. # user_groups:: UserGroup's they're members of. # in_group?:: Is User in a given UserGroup? # reviewed_images:: Image's they've reviewed. # reviewed_names:: Name's they've reviewed. # authored_names:: Name's they've authored. # edited_names:: Name's they've edited. # authored_locations:: Location's they've authored. # edited_locations:: Location's they've edited. # projects_admin:: Projects's they're an admin for. # projects_member:: Projects's they're a member of. # all_editable_species_lists:: Species Lists they own or that are attached to projects they're on. # # ==== Alerts # all_alert_types:: List of accepted alert types. # alert_user:: Which admin created the alert. # alert_created:: When alert was created. # alert_next_showing:: When is the alert going to be shown next? # alert_type:: What type of alert, e.g., :bounced_email. # alert_notes:: Additional notes to add to message. # alert_message:: Actual message, translated into local language. # # ==== Other Stuff # primer:: Primer for auto-complete. # erase_user:: Erase all references to a given User (by id). # # == Callbacks # # crypt_password:: Password attribute is encrypted before object is created. # # == Note on Globalization # # The login name must be locally unique, however a remote server could in # theory simultaneously create an account with the same login. This is dealt # with by tacking the server code on to the end locally. Thus the local # account will be unchanged, but the remote account will have a different # login name on the two servers. The end result looks like this: # # server US Fred's login Russian Fred's login # US "fred" "fred (us1)" # Russia "fred (ru1)" "fred" # # We check for this possibility in /account/login, just in case # Russian Fred tries to log in on the US server. # # In any case, the US server will _not_ know Russian Fred's password, and will # redirect him to a special page which acknowledges that he has an account on # the US server, would he like to create a password so he can login on either # server? # # There are several such attributes which are not transferred over, such as # +admin+ and +created_here+, a flag that is set to true on the server in # which the account was first created. Here is a summary of attributes that # differ from server to server: (In this example the admin User, Fred, was # created on "us1" server.) # # Attribute Local Server Remote Server # id 1502 1513 # sync_id 1502us1 1502us1 # login fred fred (us1) # password xxxxxxxx nil # admin true false # created_here true false # alert anything anything # ################################################################################ class User < AbstractModel require 'digest/sha1' has_many :comments has_many :images has_many :interests has_many :locations has_many :location_descriptions has_many :names has_many :name_descriptions has_many :namings has_many :notifications has_many :observations has_many :projects_created, :class_name => "Project" has_many :queued_emails has_many :species_lists has_many :test_add_image_logs has_many :votes has_many :donations has_many :reviewed_images, :class_name => "Image", :foreign_key => "reviewer_id" has_many :reviewed_name_descriptions, :class_name => "NameDescription", :foreign_key => "reviewer_id" has_many :to_emails, :class_name => "QueuedEmail", :foreign_key => "to_user_id" has_and_belongs_to_many :user_groups, :class_name => 'UserGroup', :join_table => 'user_groups_users' has_and_belongs_to_many :authored_names, :class_name => 'NameDescription', :join_table => 'name_descriptions_authors' has_and_belongs_to_many :edited_names, :class_name => 'NameDescription', :join_table => 'name_descriptions_editors' has_and_belongs_to_many :authored_locations, :class_name => 'LocationDescription', :join_table => 'location_descriptions_authors' has_and_belongs_to_many :edited_locations, :class_name => 'LocationDescription', :join_table => 'location_descriptions_editors' belongs_to :image # mug shot belongs_to :license # user's default license belongs_to :location # primary location # Encrypt password before saving the first time. (Subsequent modifications # go through +change_password+.) before_create :crypt_password # This causes the data structures in these fields to be serialized # automatically with YAML and stored as plain old text strings. serialize :bonuses serialize :alert # Used to let User enter location by name in prefs form. attr_accessor :place_name # Used to let User enter password confirmation when signing up or changing # password. attr_accessor :password_confirmation # Report which User is currently logged in. Returns +nil+ if none. This is # the same instance as is in the controllers' +@user+ instance variable. # # user = User.current # def self.current @@user = nil if !defined?(@@user) return @@user end # Report which User is currently logged in. Returns id, or +nil+ if none. # # user_id = User.current_id # def self.current_id @@user = nil if !defined?(@@user) return @@user && @@user.id end # Report current user's preferred location_format # # location_format = User.current_location_format # def self.current_location_format if !defined?(@@user) or @@user.nil? :postal else @@user.location_format end end # Tell User model which User is currently logged in (if any). This is used # by the +autologin+ filter. def self.current=(x) @@user = x end # Clear cached data structures when reload. def reload @projects_admin = nil @projects_member = nil @all_editable_species_lists = nil @interests = nil super end ############################################################################## # # :section: Names # ############################################################################## # Returns +login+ for debugging. def text_name login.to_s end # Return User's full name (if present) together with login. This is # guaranteed to be unique. # # name present: "Fred Flintstone (fred99)" # name missing: "fred99" # def unique_text_name if !name.blank? sprintf("%s (%s)", name, login) else login end end # Return User's full name if present, else return login. # # name present: "Fred Flintstone" # name missing: "fred99" # def legal_name if self.name.to_s != '' self.name else self.login end end def legal_name_changed? !!legal_name_change end def legal_name_change old_name = name_change[0] rescue name old_login = login_change[0] rescue login old_legal_name = old_name.blank? ? old_login : old_name new_legal_name = legal_name if old_legal_name != new_legal_name return [old_legal_name, new_legal_name] else return nil end end ############################################################################## # # :section: Authentication # ############################################################################## # Look up User record by login and hashed password. Accepts any of +login+, # +name+ or +email+ in place of +login+. # # user = User.authenticate('fred', 'password') # user = User.authenticate('Fred Flintstone', 'password') # user = User.authenticate('fred99@aol.com', 'password') # def self.authenticate(login, pass) find(:first, :conditions => [ "(login = ? OR name = ? OR email = ?) AND password = ?", login, login, login, sha1(pass) ]) end # Code used to authenticate via cookie, verify email, or XML request. # # id = params[:auth_id] # code = params[:auth_code] # user = User.find(id) # raise if code != user.auth_code # def auth_code protected_auth_code end # Change password: pass in unecrypted password, sets 'password' attribute # with a hashed copy (that is what is stored in the database). # # user.change_password('new_password') # def change_password(pass) if !pass.blank? update_attribute "password", self.class.sha1(pass) end end ############################################################################## # # :section: Groups # ############################################################################## # Is the User in a given UserGroup? (Specify group by name, not id.) # # user.in_group?('reviewers') # def in_group?(group) result = false if group.is_a?(UserGroup) user_groups.include?(group) else user_groups.any? {|g| g.name == group.to_s} end end # Return an Array of Project's that this User is an admin for. def projects_admin @projects_admin ||= Project.find_by_sql %( SELECT projects.* FROM projects, user_groups_users WHERE projects.admin_group_id = user_groups_users.user_group_id AND user_groups_users.user_id = #{id} ) end # Return an Array of Project's that this User is a member of. def projects_member @projects_member ||= Project.find_by_sql %( SELECT projects.* FROM projects, user_groups_users WHERE projects.user_group_id = user_groups_users.user_group_id AND user_groups_users.user_id = #{id} ) end # Return an Array of SpeciesList's that User owns or that are attached to a # Project that the User is a member of. def all_editable_species_lists @all_editable_species_lists ||= begin results = species_lists if projects_member.any? project_ids = projects_member.map(&:id).join(',') results += SpeciesList.find_by_sql %( SELECT species_lists.* FROM species_lists, projects_species_lists WHERE species_lists.user_id != #{id} AND projects_species_lists.project_id IN (#{project_ids}) AND projects_species_lists.species_list_id = species_lists.id ) end results end end ################################################################################ # # :section: Interests # ################################################################################ # Has this user expressed positive or negative interest in a given object? # Returns +:watching+ or +:ignoring+ if so, else +nil+. Caches result. # # case user.interest_in(observation) # when :watching; ... # when :ignoring; ... # end # def interest_in(object) @interests ||= {} @interests["#{object.class.name} #{object.id}"] ||= begin state = Interest.connection.select_value(%( SELECT state FROM interests WHERE user_id = #{id} AND target_type = '#{object.class.name}' AND target_id = #{object.id} LIMIT 1 )).to_s state == '1' ? :watching : state == '0' ? :ignoring : nil end end # Has this user expressed positive interest in a given object? # # user.watching?(observation) # def watching?(object) interest_in(object) == :watching end # Has this user expressed negative interest in a given object? # # user.ignoring?(name) # def ignoring?(object) interest_in(object) == :ignoring end ############################################################################## # # :section: Profile # ############################################################################## # Calculate the User's progress in completing their profile. It is currently # based on three equal factors: # * notes = 33% # * location = 33% # * image = 33% # def percent_complete max = 3 result = 0 if self.notes && self.notes != "" result += 1 end if self.location_id result += 1 end if self.image_id result += 1 end result * 100 / max end # Sum up all the bonuses the User has earned. # # contribution += user.sum_bonuses # def sum_bonuses if bonuses bonuses.inject(0) {|sum, pair| sum + pair[0]} end end ############################################################################## # # :section: Alerts # ############################################################################## # List of all allowed alert types. # # raise unless User.all_alert_types.include? :bogus_alert # def self.all_alert_types [:bounced_email, :other] end protected # Get alert structure, initializing it with an empty hash if necessary. def get_alert # :nodoc: self.alert ||= {} end public # When the alert was created. def alert_created get_alert[:created] end def alert_created=(x) get_alert[:created] = x end # ID of the admin User that created the alert. def alert_user_id get_alert[:user_id] end def alert_user_id=(x) get_alert[:user_id] = x end # Instance of admin User that created the alert. def alert_user User.find(alert_user_id) end def alert_user=(x) get_alert[:user_id] = x ? x.id : nil end # Next time the alert will be shown. def alert_next_showing get_alert[:next_showing] end def alert_next_showing=(x) get_alert[:next_showing] = x end # Type of alert (e.g., :bounced_email). def alert_type get_alert[:type] end def alert_type=(x) get_alert[:type] = x end # Additional notes admin added when creating alert. def alert_notes get_alert[:notes] end def alert_notes=(x) get_alert[:notes] = x end # Get the localization string for the alert message for this type of alert. # This is the actual message that will be displayed for the user in question. # # <%= user.alert_message.tp %> # def alert_message "user_alert_message_#{alert_type}".to_sym end ################################################################################ # # :section: Other # ################################################################################ # Get list of users to prime auto-completer. Returns a simple Array of up to # 1000 (by contribution or created within the last month) login String's # (with full name in parens). def self.primer result = [] if !File.exists?(USER_PRIMER_CACHE_FILE) || File.mtime(USER_PRIMER_CACHE_FILE) < Time.now - 1.day # Get list of users sorted first by when they last logged in (if recent), # then by cotribution. result = self.connection.select_values(%( SELECT CONCAT(users.login, IF(users.name = "", "", CONCAT(" <", users.name, ">"))) FROM users ORDER BY IF(last_login > CURRENT_TIMESTAMP - INTERVAL 1 MONTH, last_login, NULL) DESC, contribution DESC LIMIT 1000 )).uniq.sort open(USER_PRIMER_CACHE_FILE, 'w').write(result.join("\n") + "\n") else result = open(USER_PRIMER_CACHE_FILE, "r:UTF-8").readlines.map(&:chomp) end return result end # Erase all references to a given user (by id). Missing: # 1) *Text* references, e.g., RssLog entries refering to their login. # 2) Image votes. # 3) Personal descriptions and drafts. def self.erase_user(id) # Blank out any references in public records. for table, col in [ [:location_descriptions, :user_id], [:location_descriptions_versions, :user_id], [:locations, :user_id], [:locations_versions, :user_id], [:name_descriptions, :user_id], [:name_descriptions, :reviewer_id], [:name_descriptions_versions, :user_id], [:names, :user_id], [:names_versions, :user_id], # Leave projects, because they're intertwined with descriptions too much. [:projects, :user_id], # Leave votes and namings, because I don't want to recalc consensuses. [:namings, :user_id], [:votes, :user_id], ] User.connection.update %( UPDATE #{table} SET `#{col}` = 0 WHERE `#{col}` = #{id} ) end # Delete references to their one-user group. group = UserGroup.one_user(id) for table, col in [ [:location_descriptions_admins, :user_group_id], [:location_descriptions_readers, :user_group_id], [:location_descriptions_writers, :user_group_id], [:name_descriptions_admins, :user_group_id], [:name_descriptions_readers, :user_group_id], [:name_descriptions_writers, :user_group_id], [:user_groups, :id], ] User.connection.delete %( DELETE FROM #{table} WHERE `#{col}` = #{group.id} ) end # Delete their observations' attachments. ids = User.connection.select_values(%( SELECT id FROM observations WHERE user_id = #{id} )).map(&:to_s) if ids.any? ids = ids.join(',') for table, id, type in [ [:comments, :target_id, :target_type], [:images_observations, :observation_id], [:interests, :target_id, :target_type], [:namings, :observation_id], [:rss_logs, :observation_id], [:votes, :observation_id], ] if type User.connection.delete %( DELETE FROM #{table} WHERE `#{col}` IN (#{ids}) AND `#{type}` = 'Observation' ) else User.connection.delete %( DELETE FROM #{table} WHERE `#{col}` IN (#{ids}) ) end end end # Delete records they own, culminating in the user record itself. for table, col in [ [:comments, :user_id], [:images, :user_id], [:interests, :user_id], [:location_descriptions_authors, :user_id], [:location_descriptions_editors, :user_id], [:name_descriptions_authors, :user_id], [:name_descriptions_editors, :user_id], [:notifications, :user_id], [:observations, :user_id], [:species_lists, :user_id], [:user_groups_users, :user_id], [:users, :id], ] User.connection.delete %( DELETE FROM #{table} WHERE `#{col}` = #{id} ) end end # Does user have any unshown naming notifications? # (I'm thoroughly confused about what role the observation plays in this # complicated set of pages. -JPH) def has_unshown_naming_notifications?(observation=nil) result = false for q in QueuedEmail.find_all_by_flavor_and_to_user_id("QueuedEmail::NameTracking", self.id) naming_id, notification_id, shown = q.get_integers([:naming, :notification, :shown]) if shown.nil? notification = Notification.find(notification_id) if notification and notification.note_template result = true break end end end result end ################################################################################ protected # Encrypt a password. def self.sha1(pass) # :nodoc: Digest::SHA1.hexdigest("something__#{pass}__") end # Encrypted code used in autologin cookie and API authentication. def protected_auth_code # :nodoc: Digest::SHA1.hexdigest("SdFgJwLeR#{self.password}WeRtWeRkTj") end # This is a +before_create+ callback that encrypts the password before saving # the new user record. (Not needed for updates because we use # change_password for that instead.) def crypt_password # :nodoc: write_attribute("password", self.class.sha1(password)) end def validate # :nodoc: if self.login.to_s.blank? errors.add(:login, :validate_user_login_missing.t) elsif self.login.length < 3 or self.login.binary_length > 40 errors.add(:login, :validate_user_login_too_long.t) elsif (other = User.find_by_login(self.login)) && (other.id != self.id) errors.add(:login, :validate_user_login_taken.t) end if self.password.to_s.blank? errors.add(:password, :validate_user_password_missing.t) elsif self.password.length < 5 or password.binary_length > 40 errors.add(:password, :validate_user_password_too_long.t) end if self.email.to_s.blank? errors.add(:email, :validate_user_email_missing.t) elsif self.email.binary_length > 80 errors.add(:email, :validate_user_email_too_long.t) end if self.theme.to_s.binary_length > 40 errors.add(:theme, :validate_user_theme_too_long.t) end if self.name.to_s.binary_length > 80 errors.add(:name, :validate_user_name_too_long.t) end end def validate_on_create # :nodoc: if self.password_confirmation.to_s.blank? errors.add(:password, :validate_user_password_confirmation_missing.t) elsif self.password != self.password_confirmation errors.add(:password, :validate_user_password_no_match.t) end end end