274 lines
7.3 KiB
Ruby
274 lines
7.3 KiB
Ruby
class Domain::Fa::User < ReduxApplicationRecord
|
|
self.table_name = "domain_fa_users"
|
|
|
|
has_lite_trail(schema_version: 1)
|
|
|
|
has_many :posts,
|
|
class_name: "::Domain::Fa::Post",
|
|
inverse_of: :creator,
|
|
foreign_key: :creator_id
|
|
|
|
has_one :disco,
|
|
class_name: "::Domain::Fa::UserFactor",
|
|
inverse_of: :user,
|
|
foreign_key: :user_id,
|
|
dependent: :destroy
|
|
|
|
has_one :avatar,
|
|
class_name: "::Domain::Fa::UserAvatar",
|
|
inverse_of: :user,
|
|
dependent: :destroy
|
|
|
|
enum :state,
|
|
[
|
|
:ok, # so far so good, user may not yet be scanned
|
|
:scan_error # user has been removed or otherwise, see state_detail
|
|
]
|
|
|
|
# Who this user follows (join table)
|
|
has_many :follower_joins,
|
|
class_name: "::Domain::Fa::Follow",
|
|
foreign_key: :follower_id,
|
|
inverse_of: :follower,
|
|
dependent: :destroy
|
|
|
|
# Who this user follows (User model)
|
|
has_many :follows, through: :follower_joins, source: :followed
|
|
|
|
# Who follows this user (join table)
|
|
has_many :followed_joins,
|
|
class_name: "::Domain::Fa::Follow",
|
|
foreign_key: :followed_id,
|
|
inverse_of: :followed,
|
|
dependent: :destroy
|
|
|
|
# Who follows this user (User model)
|
|
has_many :followed_by, through: :followed_joins, source: :follower
|
|
|
|
has_many :fav_post_joins, class_name: "::Domain::Fa::Fav", inverse_of: :user
|
|
|
|
has_many :fav_posts,
|
|
class_name: "::Domain::Fa::Post",
|
|
through: :fav_post_joins,
|
|
source: :post
|
|
|
|
# FA `name` can be up to 30 chars long,
|
|
# `url_name` can be longer.
|
|
validates_presence_of(:name, :url_name)
|
|
validate do
|
|
if name && url_name
|
|
expected = self.class.name_to_url_name(name)
|
|
|
|
matches =
|
|
if name.length >= 30
|
|
url_name.starts_with?(expected)
|
|
else
|
|
url_name == expected
|
|
end
|
|
|
|
unless matches
|
|
errors.add(
|
|
:name,
|
|
"name '#{name}' does not match url_name, expected #{expected} but was #{url_name}"
|
|
)
|
|
end
|
|
end
|
|
|
|
if url_name && url_name =~ /[A-Z]/
|
|
errors.add(:url_name, "url_name '#{url_name}' contains uppercase")
|
|
end
|
|
|
|
if url_name && url_name =~ /\s/
|
|
errors.add(:url_name, "url_name '#{url_name}' contains whitespace")
|
|
end
|
|
end
|
|
|
|
after_initialize do
|
|
self.state ||= :ok
|
|
self.state_detail ||= {}
|
|
self.log_entry_detail ||= {}
|
|
end
|
|
|
|
before_destroy { throw :abort if posts.any? }
|
|
|
|
SCAN_TYPES = {
|
|
page: 1.month,
|
|
gallery: 1.year,
|
|
follows: 1.month,
|
|
favs: 1.month,
|
|
incremental: 1.month
|
|
}
|
|
|
|
SCAN_FIELD_TYPES = {
|
|
page: :column,
|
|
gallery: :column,
|
|
follows: :column,
|
|
favs: :column,
|
|
incremental: :state_detail
|
|
}
|
|
|
|
SCAN_TYPES.keys.each do |scan_type|
|
|
define_method(:"due_for_#{scan_type}_scan?") { scan_due?(scan_type) }
|
|
|
|
define_method(:"time_ago_for_#{scan_type}_scan") do
|
|
scanned_ago_in_words(scan_type)
|
|
end
|
|
|
|
next unless SCAN_FIELD_TYPES[scan_type] == :state_detail
|
|
|
|
define_method(:"scanned_#{scan_type}_at") do
|
|
get_scanned_at_value(scan_type)
|
|
end
|
|
|
|
define_method(:"scanned_#{scan_type}_at=") do |value|
|
|
set_scanned_at_value(scan_type, value)
|
|
end
|
|
end
|
|
|
|
DATE_HELPER = Class.new.extend(ActionView::Helpers::DateHelper)
|
|
|
|
def scanned_ago_in_words(scan_type)
|
|
if (timestamp = get_scanned_at_value(scan_type))
|
|
DATE_HELPER.time_ago_in_words(timestamp) + " ago"
|
|
else
|
|
"never"
|
|
end
|
|
end
|
|
|
|
def scan_due?(scan_type)
|
|
duration =
|
|
SCAN_TYPES[scan_type] || raise("invalid scan type '#{scan_type}'")
|
|
timestamp = get_scanned_at_value(scan_type)
|
|
timestamp.nil? || timestamp <= duration.ago
|
|
end
|
|
|
|
def take_posts_from(other_user)
|
|
return if other_user == self
|
|
|
|
other_posts = other_user.posts
|
|
other_posts.update_all(creator_id: id)
|
|
other_user.posts.reload
|
|
posts.reload
|
|
end
|
|
|
|
def avatar_or_create
|
|
self.class.transaction { avatar || create_avatar! }
|
|
end
|
|
|
|
def self.find_or_build_from_submission_parser(submission_parser)
|
|
unless submission_parser.is_a?(
|
|
Domain::Fa::Parser::ListedSubmissionParserHelper
|
|
) ||
|
|
submission_parser.is_a?(Domain::Fa::Parser::SubmissionParserHelper)
|
|
raise ArgumentError
|
|
end
|
|
|
|
find_or_initialize_by(url_name: submission_parser.artist_url_name) do |user|
|
|
user.name = submission_parser.artist
|
|
end
|
|
end
|
|
|
|
URL_NAME_EXCEPTIONS = { "Kammiu" => "rammiu" }
|
|
|
|
def self.name_to_url_name(name)
|
|
name = name.strip
|
|
URL_NAME_EXCEPTIONS[name] || name.delete("_").gsub(/\s/, "").downcase
|
|
end
|
|
|
|
# TODO: - write method for getting suggested users to follow
|
|
# based on this user
|
|
# something like:
|
|
# UserFactor.nearest_neighbors(
|
|
# :for_followed,
|
|
# self.disco.for_follows,
|
|
# # should this be euclidean? idk, need to test
|
|
# distance: "inner_product"
|
|
# )
|
|
# exclude self.follows.pluck(:followed_id)
|
|
|
|
# find users similar to 'self' based on who 'self' follows
|
|
def similar_users_by_follower(exclude_followed_by: nil)
|
|
similar_users_by(:for_follower, exclude_followed_by)
|
|
end
|
|
|
|
# find users similar to 'self one based on who follows 'self'
|
|
def similar_users_by_followed(exclude_followed_by: nil)
|
|
similar_users_by(:for_followed, exclude_followed_by)
|
|
end
|
|
|
|
def guess_user_page_log_entry
|
|
for_path =
|
|
proc do |uri_path|
|
|
HttpLogEntry
|
|
.where(
|
|
uri_scheme: "https",
|
|
uri_host: "www.furaffinity.net",
|
|
uri_path: uri_path
|
|
)
|
|
.order(created_at: :desc)
|
|
.first
|
|
end
|
|
|
|
for_hle_id = proc { |hle_id| hle_id && HttpLogEntry.find_by(id: hle_id) }
|
|
|
|
# older versions don't end in a trailing slash
|
|
hle_id = self.log_entry_detail && self.log_entry_detail["last_user_page_id"]
|
|
|
|
# first try the last scanned user page (present on most fa user models)
|
|
for_hle_id.call(hle_id) ||
|
|
# if that's missing, see if there's an existing request logged to the user page
|
|
for_path.call("/user/#{url_name}/") ||
|
|
# and try the non-trailing-slash version as well
|
|
for_path.call("/user/#{url_name}")
|
|
|
|
# TODO: - maybe can look for posts as well, those might list an avatar
|
|
end
|
|
|
|
def to_param
|
|
url_name
|
|
end
|
|
|
|
private
|
|
|
|
def similar_users_by(factor_col, exclude_followed_by)
|
|
query = disco.nearest_neighbors(factor_col, distance: "euclidean")
|
|
|
|
query =
|
|
query.where.not(
|
|
user_id: exclude_followed_by.follows.select(:followed_id)
|
|
) if exclude_followed_by
|
|
|
|
users_from_disco_query(query)
|
|
end
|
|
|
|
def users_from_disco_query(disco_query)
|
|
Domain::Fa::User
|
|
.select("domain_fa_users.*", disco_query.select_values.last)
|
|
.joins(:disco)
|
|
.merge(disco_query.reselect(:user_id))
|
|
end
|
|
|
|
def get_scanned_at_value(scan_type)
|
|
case SCAN_FIELD_TYPES[scan_type]
|
|
when :column
|
|
send(:"scanned_#{scan_type}_at")
|
|
when :state_detail
|
|
str = state_detail["scanned_#{scan_type}_at"]
|
|
Time.parse(str) if str
|
|
else
|
|
raise("invalid scan type '#{scan_type}'")
|
|
end
|
|
end
|
|
|
|
def set_scanned_at_value(scan_type, value)
|
|
case SCAN_FIELD_TYPES[scan_type]
|
|
when :column
|
|
send(:"scanned_#{scan_type}_at=", value)
|
|
when :state_detail
|
|
state_detail["scanned_#{scan_type}_at"] = value.iso8601
|
|
else
|
|
raise("invalid scan type '#{scan_type}'")
|
|
end
|
|
end
|
|
end
|