app/models/user.rb
# == Schema Information
#
# Table name: users
#
# id :integer not null, primary key
# email :string(255) default(""), not null
# name :string(255)
# encrypted_password :string(255) default(""), not null
# reset_password_token :string(255)
# reset_password_sent_at :datetime
# remember_created_at :datetime
# sign_in_count :integer default(0)
# current_sign_in_at :datetime
# last_sign_in_at :datetime
# current_sign_in_ip :string(255)
# last_sign_in_ip :string(255)
# created_at :datetime not null
# updated_at :datetime not null
# recommendations_up_to_date :boolean
# avatar_file_name :string(255)
# avatar_content_type :string(255)
# avatar_file_size :integer
# avatar_updated_at :datetime
# facebook_id :string(255)
# bio :string(140) default(""), not null
# sfw_filter :boolean default(TRUE)
# star_rating :boolean default(FALSE)
# mal_username :string(255)
# life_spent_on_anime :integer default(0), not null
# about :string(500) default(""), not null
# confirmation_token :string(255)
# confirmed_at :datetime
# confirmation_sent_at :datetime
# unconfirmed_email :string(255)
# cover_image_file_name :string(255)
# cover_image_content_type :string(255)
# cover_image_file_size :integer
# cover_image_updated_at :datetime
# title_language_preference :string(255) default("canonical")
# followers_count_hack :integer default(0)
# following_count :integer default(0)
# ninja_banned :boolean default(FALSE)
# last_library_update :datetime
# last_recommendations_update :datetime
# authentication_token :string(255)
# avatar_processing :boolean
# subscribed_to_newsletter :boolean default(TRUE)
# waifu :string(255)
# location :string(255)
# website :string(255)
# waifu_or_husbando :string(255)
# waifu_slug :string(255) default("#")
# waifu_char_id :string(255) default("0000")
# to_follow :boolean default(FALSE)
# dropbox_token :string(255)
# dropbox_secret :string(255)
# last_backup :datetime
# approved_edit_count :integer default(0)
# rejected_edit_count :integer default(0)
# pro_expires_at :datetime
# stripe_token :string(255)
# pro_membership_plan_id :integer
# stripe_customer_id :string(255)
# about_formatted :text
# import_status :integer
# import_from :string(255)
# import_error :string(255)
#
class User < ActiveRecord::Base
# Friendly ID.
def to_param
name
end
def self.find(id)
user = nil
if id.is_a? String
user = User.find_by_username(id)
end
user || super
end
def self.find_by_username(username)
where('LOWER(name) = ?', username.to_s.downcase).first
end
def self.match(query)
where('LOWER(name) = ?', query.to_s.downcase)
end
def self.search(query)
# Gnarly hack to provide a search rank
# TODO: switch properly to pg_search (this is harder for User because of
# maintaining the email search, unless we remove that)
select(
sanitize_sql_array([
'users.*, GREATEST(
similarity(users.name, :query),
CASE WHEN users.email = :query THEN 1.0 ELSE 0.0 END
) AS pg_search_rank',
query: query.downcase])
).where('LOWER(name) LIKE :query OR LOWER(email) LIKE :query', query: "#{query.downcase}%")
end
class << self
alias_method :instant_search, :search
alias_method :full_search, :instant_search
end
has_many :favorites
def has_favorite?(item)
self.favorites.exists?(item_id: item, item_type: item.class.to_s)
end
def has_favorite2?(item)
@favorites ||= favorites.pluck(:item_id, :item_type)
!! @favorites.member?([item.id, item.class.to_s])
end
scope :pro_expires_this_month, -> { where(pro_expires_at: Date.today.all_month) }
scope :recurring_pro, -> { where(pro_membership_plan_id: ProMembershipPlan.recurring_plans.map(&:id)) }
# Following stuff.
has_many :follower_relations, dependent: :destroy, foreign_key: :followed_id, class_name: 'Follow'
has_many :followers, -> { order('follows.created_at DESC') }, through: :follower_relations, source: :follower, class_name: 'User'
has_many :follower_items, -> { select('"follows"."follower_id", "follows"."followed_id"') }, foreign_key: :followed_id, class_name: 'Follow'
has_many :following_relations, dependent: :destroy, foreign_key: :follower_id, class_name: 'Follow'
has_many :following, -> { order('follows.created_at DESC') }, through: :following_relations, source: :followed, class_name: 'User'
# Groups stuff.
has_many :group_relations, dependent: :destroy, foreign_key: :user_id, class_name: 'GroupMember'
has_many :groups, through: :group_relations
has_many :stories
has_many :substories
has_many :notifications
has_many :votes
has_one :recommendation
has_many :not_interested
has_many :not_interested_anime, through: :not_interested, source: :media, source_type: "Anime"
has_and_belongs_to_many :favorite_genres, -> { uniq }, class_name: "Genre", join_table: "favorite_genres_users"
belongs_to :waifu_character, foreign_key: :waifu_char_id, class_name: 'Casting', primary_key: :character_id
# Include devise modules. Others available are:
# :lockable, :timeoutable, :trackable, :rememberable.
devise :database_authenticatable, :registerable, :recoverable,
:validatable, :omniauthable, :confirmable, :async,
allow_unconfirmed_access_for: nil
has_attached_file :avatar,
styles: {
thumb: '190x190#',
thumb_small: {geometry: '100x100#', animated: false, format: :jpg},
small: {geometry: '50x50#', animated: false, format: :jpg}
},
convert_options: {
thumb_small: '-quality 0',
small: '-quality 0'
},
default_url: "https://hummingbird.me/default_avatar.jpg",
processors: [:thumbnail, :paperclip_optimizer]
validates_attachment :avatar, content_type: {
content_type: ["image/jpg", "image/jpeg", "image/png", "image/gif"]
}
process_in_background :avatar, processing_image_url: '/assets/processing-avatar.jpg'
has_attached_file :cover_image,
styles: {thumb: {geometry: "2880x800#", animated: false, format: :jpg}},
convert_options: {thumb: '-interlace Plane -quality 0'},
default_url: "https://hummingbird.me/default_cover.png"
validates_attachment :cover_image, content_type: {
content_type: ["image/jpg", "image/jpeg", "image/png", "image/gif"]
}
has_many :library_entries, dependent: :destroy
has_many :manga_library_entries, dependent: :destroy
has_many :reviews
has_many :quotes
# Validations
validates :name,
:presence => true,
:uniqueness => {:case_sensitive => false},
:length => {minimum: 3, maximum: 20},
:format => {:with => /\A[_A-Za-z0-9]+\z/,
:message => "can only contain letters, numbers, and underscores."}
INVALID_USERNAMES = %w(
admin administrator connect dashboard developer developers edit favorites
feature featured features feed follow followers following hummingbird index
javascript json sysadmin sysadministrator system unfollow user users wiki you
)
validate :valid_username
def valid_username
return unless name
if INVALID_USERNAMES.include? name.downcase
errors.add(:name, "is reserved")
end
if name[0,1] =~ /[^A-Za-z0-9]/
errors.add(:name, "must begin with a letter or number")
end
if name =~ /^[0-9]*$/
errors.add(:name, "cannot be entirely numbers")
end
end
validates :facebook_id, allow_blank: true, uniqueness: true
validates :title_language_preference, inclusion: {in: %w[canonical english romanized]}
enum import_status: {queued: 1, running: 2, complete: 3, error: 4}
def to_s
name
end
# Avatar
def avatar_url
# Gravatar
# gravatar_id = Digest::MD5.hexdigest(email.downcase)
# "http://gravatar.com/avatar/#{gravatar_id}.png?s=100"
avatar.url(:thumb)
end
# Public: Is this user an administrator?
#
# For now, this will just check email addresses. In production, this should
# check the user's ID as well.
def admin?
["c@vikhyat.net", # Vik
"josh@hummingbird.me", # Josh
"hummingbird.ryn@gmail.com", # Ryatt
"dev.colinl@gmail.com", # Psy
"lazypanda39@gmail.com", # Cai
"svengehring@cybrox.eu", # Cybrox
"peter.lejeck@gmail.com", # Nuck
"hello@vevix.net", # Vevix
"jimm4a1@hotmail.com", #Jim
"jojovonjo@yahoo.com", #JoJo
"synthtech@outlook.com" #Synthtech
].include? email
end
# Does the user have active PRO membership?
def pro?
pro_expires_at && pro_expires_at > Time.now
end
def pro_membership_plan
return nil if pro_membership_plan_id.nil?
ProMembershipPlan.find(pro_membership_plan_id)
end
def has_dropbox?
!!(dropbox_token && dropbox_secret)
end
def has_facebook?
!facebook_id.blank?
end
# Public: Find a user corresponding to a Facebook account.
#
# If there is an account associated with the Facebook ID, return it.
#
# If there is no such account but `signed_in_resource` is not nil (meaning that
# there is a user signed in), connect the user's account to this Facebook
# account.
#
# If there is no user logged in, check to see if there is a user with the same
# email address. If there is, connect that account to Facebook and return it.
#
# Otherwise, just create a new user and connect it to this Facebook account.
#
# Returns a user account corresponding to the given auth parameters.
def self.find_for_facebook_oauth(auth, signed_in_resource=nil)
# Try to find a user already associated with the Facebook ID.
user = User.where(facebook_id: auth.uid).first
return user if user
# If the user is logged in, connect their account to Facebook.
if not signed_in_resource.nil?
signed_in_resource.connect_to_facebook(auth.uid)
return signed_in_resource
end
# If there is a user with the same email, connect their account to this
# Facebook account.
user = User.find_by_email(auth.info.email)
if user
user.connect_to_facebook(auth.uid)
return user
end
# Just create a new account. >_>
name = auth.extra.raw_info.name.parameterize.gsub('-', '_')
name = name.gsub(/[^_A-Za-z0-9]/, '')
if User.where("LOWER(name) = ?", name.downcase).count > 0
if name.length > 20
name = name[0...15]
end
name = name[0...10] + rand(9999).to_s
end
name = name[0...20] if name.length > 20
user = User.new(
name: name,
facebook_id: auth.uid,
email: auth.info.email,
avatar: open("https://graph.facebook.com/#{auth.uid}/picture?width=200&height=200"),
password: Devise.friendly_token[0, 20]
)
user.confirm!
return user
end
# Set this user's facebook_id to the passed in `uid`.
#
# Returns nothing.
def connect_to_facebook(uid)
if not self.avatar.exists?
self.avatar = open("http://graph.facebook.com/#{uid}/picture?width=200&height=200")
end
self.facebook_id = uid
self.save
end
def update_ip!(new_ip)
if self.current_sign_in_ip != new_ip
self.attributes = {
current_sign_in_ip: new_ip,
last_sign_in_ip: self.current_sign_in_ip
}
# Avoid validating because some users apparently don't pass validation
self.save(validate: false)
end
end
# Return the top 5 genres the user has completed, along with
# the number of anime watched that contain each of those genres.
def top_genres
freqs = nil
LibraryEntry.unscoped do
freqs = library_entries.where(status: "Completed")
.where(private: false)
.joins(:genres)
.group('genres.id')
.select('COUNT(*) as count, genres.id as genre_id')
.order('count DESC')
.limit(5).each_with_object({}) do |x, obj|
obj[x.genre_id] = x.count.to_f
end
end
result = []
Genre.where(id: freqs.keys).each do |genre|
result.push({genre: genre, num: freqs[genre.id]})
end
result.sort_by {|x| -x[:num] }
end
# How many minutes the user has spent watching anime.
def recompute_life_spent_on_anime!
time_spent = nil
LibraryEntry.unscoped do
time_spent = self.library_entries.joins(:anime).select('
COALESCE(anime.episode_length, 0) * (
COALESCE(episodes_watched, 0)
+ COALESCE(anime.episode_count, 0) * COALESCE(rewatch_count, 0)
) AS mins
').map {|x| x.mins }.sum
end
self.update_attributes life_spent_on_anime: time_spent
end
def update_life_spent_on_anime(delta)
if life_spent_on_anime == 0
self.recompute_life_spent_on_anime!
else
self.update_column :life_spent_on_anime, self.life_spent_on_anime + delta
end
end
def followers_count
followers_count_hack
end
before_save do
if self.facebook_id and self.facebook_id.strip == ""
self.facebook_id = nil
end
# Make sure the user has an authentication token.
if self.authentication_token.blank?
token = nil
loop do
token = Devise.friendly_token
break unless User.where(authentication_token: token).first
end
self.authentication_token = token
end
if about_changed?
self.about_formatted = MessageFormatter.format_message about
end
if waifu_char_id != '0000' and changed_attributes['waifu_char_id']
self.waifu_slug = waifu_character ? waifu_character.castable.slug : '#'
end
end
def sync_to_forum!
UserSyncWorker.perform_async(id) if Rails.env.production?
end
def sync_to_staging!
StagingSyncWorker.perform_async(id) if Rails.env.production?
true
end
def sync_to_staging?
name_changed? || encrypted_password_changed? || email_changed? ||
pro_expires_at_changed?
end
after_save :sync_to_staging!, if: :sync_to_staging?
after_save do
avatar_changed = !avatar_processing && avatar_processing_changed?
sync_to_forum! if name_changed? || avatar_changed || pro_expires_at_changed?
if pro_expires_at_changed? && (pro_expires_at_was || 2.days.ago) < Time.now
UserMailer.pro_kitsu_message(self).deliver
end
true
end
def voted_for?(target)
@votes ||= {}
@votes[target.class.to_s] ||= votes.where(:target_type => target.class.to_s).pluck(:target_id)
@votes[target.class.to_s].member? target.id
end
def avatar_template
self.avatar.url(:thumb).gsub(/users\/avatars\/(\d+\/\d+\/\d+)\/\w+/, "users/avatars/\\1/{size}")
end
attr_reader :is_followed
def set_is_followed!(v)
@is_followed = v
end
def to_discourse_sso
DiscourseApi::SingleSignOn.new.tap do |sso|
sso.email = email
sso.username = name
sso.external_id = id
sso.suppress_welcome_message = true
sso.avatar_url = avatar.url(:thumb)
sso.custom_fields[:pro_expires_at] = pro_expires_at
end
end
end