Files
danbooru/app/models/post.rb
2011-03-14 14:08:29 -04:00

878 lines
25 KiB
Ruby

class Post < ActiveRecord::Base
class ApprovalError < Exception ; end
class DisapprovalError < Exception ; end
attr_accessor :old_tag_string, :old_parent_id
after_destroy :delete_files
after_save :create_version
after_save :update_parent_on_save
before_save :merge_old_tags
before_save :normalize_tags
before_save :create_tags
before_save :update_tag_post_counts
before_save :set_tag_counts
before_validation :initialize_uploader, :on => :create
belongs_to :updater, :class_name => "User"
belongs_to :approver, :class_name => "User"
belongs_to :parent, :class_name => "Post"
has_one :unapproval, :dependent => :destroy
has_one :upload, :dependent => :destroy
has_many :versions, :class_name => "PostVersion", :dependent => :destroy, :order => "post_versions.id ASC"
has_many :votes, :class_name => "PostVote", :dependent => :destroy
has_many :notes, :dependent => :destroy
has_many :comments
has_many :children, :class_name => "Post", :foreign_key => "parent_id", :order => "posts.id"
has_many :disapprovals, :class_name => "PostDisapproval"
validates_uniqueness_of :md5
validates_presence_of :parent, :if => lambda {|rec| !rec.parent_id.nil?}
validate :validate_parent_does_not_have_a_parent
attr_accessible :source, :rating, :tag_string, :old_tag_string, :last_noted_at
scope :pending, where(["is_pending = ?", true])
scope :undeleted, where(["is_deleted = ?", false])
scope :visible, lambda {|user| Danbooru.config.can_user_see_post_conditions(user)}
scope :commented_before, lambda {|date| where("last_commented_at < ?", date).order("last_commented_at DESC")}
scope :available_for_moderation, lambda {where(["id NOT IN (SELECT pd.post_id FROM post_disapprovals pd WHERE pd.user_id = ?)", CurrentUser.id])}
scope :hidden_from_moderation, lambda {where(["id IN (SELECT pd.post_id FROM post_disapprovals pd WHERE pd.user_id = ?)", CurrentUser.id])}
scope :before_id, lambda {|id| id.present? ? where(["posts.id < ?", id]) : where("TRUE")}
scope :tag_match, lambda {|query| Post.tag_match_helper(query)}
search_method :tag_match
module FileMethods
def delete_files
FileUtils.rm_f(file_path)
FileUtils.rm_f(medium_file_path)
FileUtils.rm_f(large_file_path)
FileUtils.rm_f(preview_file_path)
end
def file_path_prefix
Rails.env == "test" ? "test." : ""
end
def file_path
"#{Rails.root}/public/data/original/#{file_path_prefix}#{md5}.#{file_ext}"
end
def medium_file_path
if has_medium?
"#{Rails.root}/public/data/medium/#{file_path_prefix}#{md5}.jpg"
else
file_path
end
end
def large_file_path
if has_large?
"#{Rails.root}/public/data/large/#{file_path_prefix}#{md5}.jpg"
else
file_path
end
end
def preview_file_path
"#{Rails.root}/public/data/preview/#{file_path_prefix}#{md5}.jpg"
end
def file_url
"/data/original/#{file_path_prefix}#{md5}.#{file_ext}"
end
def medium_file_url
if has_medium?
"/data/medium/#{file_path_prefix}#{md5}.jpg"
else
file_url
end
end
def large_file_url
if has_large?
"/data/large/#{file_path_prefix}#{md5}.jpg"
else
medium_file_url
end
end
def preview_file_url
"/data/preview/#{file_path_prefix}#{md5}.jpg"
end
def file_url_for(user)
case user.default_image_size
when "medium"
if image_width > Danbooru.config.medium_image_width
medium_file_url
else
file_url
end
when "large"
if image_width > Danbooru.config.large_image_width
large_file_url
else
file_url
end
else
file_url
end
end
def file_path_for(user)
case user.default_image_size
when "medium"
if image_width > Danbooru.config.medium_image_width
medium_file_path
else
file_path
end
when "large"
if image_width > Danbooru.config.large_image_width
large_file_path
else
file_path
end
else
file_path
end
end
def is_image?
file_ext =~ /jpg|gif|png/
end
def is_flash?
file_ext =~ /swf/
end
end
module ImageMethods
def has_medium?
image_width > Danbooru.config.medium_image_width
end
def has_large?
image_width > Danbooru.config.large_image_width
end
def medium_image_width
[Danbooru.config.medium_image_width, image_width].min
end
def large_image_width
[Danbooru.config.large_image_width, image_width].min
end
def medium_image_height
ratio = Danbooru.config.medium_image_width.to_f / image_width.to_f
if ratio < 1
(image_height * ratio).to_i
else
image_height
end
end
def large_image_height
ratio = Danbooru.config.large_image_width.to_f / image_width.to_f
if ratio < 1
(image_height * ratio).to_i
else
image_height
end
end
def image_width_for(user)
case user.default_image_size
when "medium"
medium_image_width
when "large"
large_image_width
else
image_width
end
end
def image_height_for(user)
case user.default_image_size
when "medium"
medium_image_height
when "large"
large_image_height
else
image_height
end
end
end
module ApprovalMethods
def is_unapprovable?
is_pending == false && is_flagged == false && unapproval.nil?
end
def is_approvable?
(is_pending? || is_flagged?) && approver_string != "approver:#{CurrentUser.name}"
end
def unapprove!(reason)
raise Unapproval::Error.new("This post is still pending approval") if is_pending?
raise Unapproval::Error.new("This post has already been flagged") if is_flagged?
raise Unapproval::Error.new("This post has already been unapproved once") unless unapproval.nil?
unapproval = create_unapproval(
:unapprover_id => CurrentUser.user.id,
:unapprover_ip_addr => CurrentUser.ip_addr,
:reason => reason
)
if unapproval.errors.any?
raise Unapproval::Error.new(unapproval.errors.full_messages.join("; "))
end
update_attribute(:is_flagged, true)
end
def approve!
raise ApprovalError.new("You have previously approved this post and cannot approve it again") if approver_string == "approver:#{CurrentUser.name}"
self.is_flagged = false
self.is_pending = false
self.approver_string = "approver:#{CurrentUser.name}"
save!
end
end
module PresenterMethods
def presenter
@presenter ||= PostPresenter.new(self)
end
def pretty_rating
case rating
when "q"
"Questionable"
when "e"
"Explicit"
when "s"
"Safe"
end
end
end
module TagMethods
def tag_array
@tag_array ||= Tag.scan_tags(tag_string)
end
def tag_array_was
@tag_array_was ||= Tag.scan_tags(tag_string_was)
end
def create_tags
set_tag_string(tag_array.map {|x| Tag.find_or_create_by_name(x).name}.join(" "))
end
def increment_tag_post_counts
execute_sql("UPDATE tags SET post_count = post_count + 1 WHERE name IN (?)", tag_array) if tag_array.any?
end
def decrement_tag_post_counts
execute_sql("UPDATE tags SET post_count = post_count - 1 WHERE name IN (?)", tag_array) if tag_array.any?
end
def update_tag_post_counts
decrement_tags = tag_array_was - tag_array
increment_tags = tag_array - tag_array_was
execute_sql("UPDATE tags SET post_count = post_count - 1 WHERE name IN (?)", decrement_tags) if decrement_tags.any?
execute_sql("UPDATE tags SET post_count = post_count + 1 WHERE name IN (?)", increment_tags) if increment_tags.any?
decrement_tags.each do |tag|
expire_cache(tag)
end
increment_tags.each do |tag|
expire_cache(tag)
end
expire_cache("")
end
def set_tag_counts
self.tag_count = 0
self.tag_count_general = 0
self.tag_count_artist = 0
self.tag_count_copyright = 0
self.tag_count_character = 0
categories = Tag.categories_for(tag_array)
categories.each_value do |category|
self.tag_count += 1
case category
when Tag.categories.general
self.tag_count_general += 1
when Tag.categories.artist
self.tag_count_artist += 1
when Tag.categories.copyright
self.tag_count_copyright += 1
when Tag.categories.character
self.tag_count_character += 1
end
end
end
def merge_old_tags
if old_tag_string
# If someone else committed changes to this post before we did,
# then try to merge the tag changes together.
current_tags = tag_array_was()
new_tags = tag_array()
old_tags = Tag.scan_tags(old_tag_string)
set_tag_string(((current_tags + new_tags) - old_tags + (current_tags & new_tags)).uniq.join(" "))
end
end
def reset_tag_array_cache
@tag_array = nil
@tag_array_was = nil
end
def set_tag_string(string)
self.tag_string = string
reset_tag_array_cache
end
def normalize_tags
normalized_tags = Tag.scan_tags(tag_string)
normalized_tags = TagAlias.to_aliased(normalized_tags)
normalized_tags = TagImplication.with_descendants(normalized_tags)
normalized_tags = filter_metatags(normalized_tags)
set_tag_string(normalized_tags.uniq.join(" "))
end
def filter_metatags(tags)
tags.reject {|tag| tag =~ /\A(?:pool|rating|fav|approver|uploader):/}
end
def has_tag?(tag)
tag_string =~ /(?:^| )#{tag}(?:$| )/
end
end
module FavoriteMethods
def delete_favorites
Favorite.destroy_for_post(self)
end
def add_favorite(user)
if user.is_a?(ActiveRecord::Base)
user_id = user.id
else
user_id = user
end
return false if fav_string =~ /(?:\A| )fav:#{user_id}(?:\Z| )/
self.fav_string += " fav:#{user_id}"
self.fav_string.strip!
# in order to avoid rerunning the callbacks, just update through raw sql
execute_sql("UPDATE posts SET fav_string = ? WHERE id = ?", fav_string, id)
Favorite.create(:user_id => user_id, :post_id => id)
end
def remove_favorite(user)
if user.is_a?(ActiveRecord::Base)
user_id = user.id
else
user_id = user
end
self.fav_string.gsub!(/(?:\A| )fav:#{user_id}(?:\Z| )/, " ")
self.fav_string.strip!
# in order to avoid rerunning the callbacks, just update through raw sql
execute_sql("UPDATE posts SET fav_string = ? WHERE id = ?", fav_string, id)
Favorite.destroy(:user_id => user_id, :post_id => id)
end
def favorited_user_ids
fav_string.scan(/\d+/)
end
end
module SearchMethods
class SearchError < Exception ; end
def add_range_relation(arr, field, relation)
case arr[0]
when :eq
relation.where(["#{field} = ?", arr[1]])
when :gt
relation.where(["#{field} > ?", arr[1]])
when :gte
relation.where(["#{field} >= ?", arr[1]])
when :lt
relation.where(["#{field} < ?", arr[1]])
when :lte
relation.where(["#{field} <= ?", arr[1]])
when :between
relation.where(["#{field} BETWEEN ? AND ?", arr[1], arr[2]])
else
relation
end
end
def escape_string_for_tsquery(array)
array.map do |token|
escaped_token = token.gsub(/\\|'/, '\0\0\0\0').gsub("?", "\\\\77").gsub("%", "\\\\37")
"''" + escaped_token + "''"
end
end
def add_tag_string_search_relation(tags, relation)
tag_query_sql = []
if tags[:include].any?
tag_query_sql << "(" + escape_string_for_tsquery(tags[:include]).join(" | ") + ")"
end
if tags[:related].any?
raise SearchError.new("You cannot search for more than #{Danbooru.config.tag_query_limit} tags at a time") if tags[:related].size > Danbooru.config.tag_query_limit
tag_query_sql << "(" + escape_string_for_tsquery(tags[:related]).join(" & ") + ")"
end
if tags[:exclude].any?
raise SearchError.new("You cannot search for more than #{Danbooru.config.tag_query_limit} tags at a time") if tags[:exclude].size > Danbooru.config.tag_query_limit
if tags[:related].any? || tags[:include].any?
tag_query_sql << "!(" + escape_string_for_tsquery(tags[:exclude]).join(" | ") + ")"
else
raise SearchError.new("You cannot search for only excluded tags")
end
end
if tag_query_sql.any?
relation = relation.where("posts.tag_index @@ to_tsquery('danbooru', E'" + tag_query_sql.join(" & ") + "')")
end
relation
end
def add_tag_subscription_relation(subscriptions, relation)
subscriptions.each do |subscription|
subscription =~ /^(.+?):(.+)$/
user_name = $1 || subscription
subscription_name = $2
user = User.find_by_name(user_name)
if user
post_ids = TagSubscription.find_post_ids(user.id, subscription_name)
relation = relation.where(["posts.id IN (?)", post_ids])
end
end
relation
end
def tag_match_helper(q)
unless q.is_a?(Hash)
q = Tag.parse_query(q)
end
relation = Post.scoped
relation = add_range_relation(q[:post_id], "posts.id", relation)
relation = add_range_relation(q[:mpixels], "posts.width * posts.height / 1000000.0", relation)
relation = add_range_relation(q[:width], "posts.image_width", relation)
relation = add_range_relation(q[:height], "posts.image_height", relation)
relation = add_range_relation(q[:score], "posts.score", relation)
relation = add_range_relation(q[:filesize], "posts.file_size", relation)
relation = add_range_relation(q[:date], "posts.created_at::date", relation)
relation = add_range_relation(q[:general_tag_count], "posts.tag_count_general", relation)
relation = add_range_relation(q[:artist_tag_count], "posts.tag_count_artist", relation)
relation = add_range_relation(q[:copyright_tag_count], "posts.tag_count_copyright", relation)
relation = add_range_relation(q[:character_tag_count], "posts.tag_count_character", relation)
relation = add_range_relation(q[:tag_count], "posts.tag_count", relation)
if q[:md5].any?
relation = relation.where(["posts.md5 IN (?)", q[:md5]])
end
if q[:status] == "pending"
relation = relation.where("posts.is_pending = TRUE")
elsif q[:status] == "flagged"
relation = relation.where("posts.is_flagged = TRUE")
elsif q[:status] == "deleted"
relation = relation.where("posts.is_deleted = TRUE")
end
if q[:source].is_a?(String)
relation = relation.where(["posts.source LIKE ? ESCAPE E'\\\\'", q[:source]])
end
if q[:subscriptions].any?
relation = add_tag_subscription_relation(q[:subscriptions], relation)
end
relation = add_tag_string_search_relation(q[:tags], relation)
if q[:rating] == "q"
relation = relation.where("posts.rating = 'q'")
elsif q[:rating] == "s"
relation = relation.where("posts.rating = 's'")
elsif q[:rating] == "e"
relation = relation.where("posts.rating = 'e'")
end
if q[:rating_negated] == "q"
relation = relation.where("posts.rating <> 'q'")
elsif q[:rating_negated] == "s"
relation = relation.where("posts.rating <> 's'")
elsif q[:rating_negated] == "e"
relation = relation.where("posts.rating <> 'e'")
end
case q[:order]
when "id", "id_asc"
relation = relation.order("posts.id")
when "id_desc"
relation = relation.order("posts.id DESC")
when "score", "score_desc"
relation = relation.order("posts.score DESC, posts.id DESC")
when "score_asc"
relation = relation.order("posts.score, posts.id DESC")
when "mpixels", "mpixels_desc"
# Use "w*h/1000000", even though "w*h" would give the same result, so this can use
# the posts_mpixels index.
relation = relation.order("posts.image_width * posts.image_height / 1000000.0 DESC, posts.id DESC")
when "mpixels_asc"
relation = relation.order("posts.image_width * posts.image_height / 1000000.0, posts.id DESC")
when "portrait"
relation = relation.order("1.0 * posts.image_width / GREATEST(1, posts.image_height), posts.id DESC")
when "landscape"
relation = relation.order("1.0 * posts.image_width / GREATEST(1, posts.image_height) DESC, posts.id DESC")
when "filesize", "filesize_desc"
relation = relation.order("posts.file_size DESC")
when "filesize_asc"
relation = relation.order("posts.file_size")
else
relation = relation.order("posts.id DESC")
end
relation
end
end
module UploaderMethods
def initialize_uploader
self.uploader = CurrentUser.user
self.uploader_ip_addr = CurrentUser.ip_addr
end
def uploader_id=(user_id)
self.uploader = User.find(user_id)
end
def uploader_id
uploader_string[9..-1].to_i
end
def uploader_name
User.id_to_name(uploader_id)
end
def uploader
User.find(uploader_id)
end
def uploader=(user)
self.uploader_string = "uploader:#{user.id}"
end
end
module PoolMethods
def pools
@pools ||= begin
pool_ids = pool_string.scan(/\d+/)
Pool.where(["id in (?)", pool_ids])
end
end
def add_pool(pool)
return if pool_string =~ /(?:\A| )pool:#{pool.id}(?:\Z| )/
self.pool_string += " pool:#{pool.id}"
self.pool_string.strip!
execute_sql("UPDATE posts SET pool_string = ? WHERE id = ?", pool_string, id)
end
def remove_pool(pool)
self.pool_string.gsub!(/(?:\A| )pool:#{pool.id}(?:\Z| )/, " ")
self.pool_string.strip!
execute_sql("UPDATE posts SET pool_string = ? WHERE id = ?", pool_string, id)
end
end
module VoteMethods
def can_be_voted_by?(user)
!votes.exists?(["user_id = ?", user.id])
end
def vote!(score)
if can_be_voted_by?(CurrentUser.user)
if score == "up"
increment!(:score)
increment!(:up_score)
elsif score == "down"
decrement!(:score)
decrement!(:down_score)
end
votes.create(:score => score)
else
raise PostVote::Error.new("You have already voted for this comment")
end
end
end
module CountMethods
def get_count_from_cache(tags)
Cache.get(count_cache_key(tags))
end
def set_count_in_cache(tags, count)
if count < 100
expiry = 0
else
expiry = (count * 4).minutes
end
Cache.put(count_cache_key(tags), count, expiry)
end
def count_cache_key(tags)
"pfc:#{Cache.sanitize(tags)}"
end
def fast_count(tags = "")
tags = tags.to_s
count = get_count_from_cache(tags)
if count.nil?
count = Post.tag_match(tags).undeleted.count
if count > Danbooru.config.posts_per_page * 10
set_count_in_cache(tags, count)
end
end
count
end
end
module CacheMethods
def expire_cache(tag_name)
if Post.fast_count("") < 1000
Cache.delete(Post.count_cache_key(""))
end
Cache.delete(Post.count_cache_key(tag_name))
end
end
module ParentMethods
# A parent has many children. A child belongs to a parent.
# A parent cannot have a parent.
#
# After deleting a child:
# - Move favorites to parent.
# - Does the parent have any active children?
# - Yes: Done.
# - No: Update parent's has_children flag to false.
#
# After deleting a parent:
# - Move favorites to the first child.
# - Reparent all active children to the first active child.
module ClassMethods
def update_has_children_flag_for(post_id)
has_children = Post.exists?(["is_deleted = ? AND parent_id = ?", false, post_id])
execute_sql("UPDATE posts SET has_children = ? WHERE id = ?", has_children, post_id)
end
def recalculate_has_children_for_all_posts
transaction do
execute_sql("UPDATE posts SET has_children = false WHERE has_children = true")
execute_sql("UPDATE posts SET has_children = true WHERE id IN (SELECT p.parent_id FROM posts p WHERE p.parent_id IS NOT NULL AND is_deleted = FALSE)")
end
end
end
def self.included(m)
m.extend(ClassMethods)
end
def validate_parent_does_not_have_a_parent
return if parent.nil?
if !parent.parent.nil?
errors.add(:parent, "can not have a parent")
end
end
def update_parent_on_destroy
Post.update_has_children_flag_for(parent_id)
Post.update_has_children_flag_for(parent_id_was) if parent_id_was && parent_id != parent_id_was
end
def update_children_on_destroy
if children.size == 0
# do nothing
elsif children.size == 1
children.first.update_attribute(:parent_id, nil)
else
cached_children = children
cached_children[1..-1].each do |child|
child.update_attribute(:parent_id, cached_children[0].id)
end
cached_children[0].update_attribute(:parent_id, nil)
end
end
def update_parent_on_save
if parent_id == parent_id_was
# do nothing
elsif !parent_id_was.nil?
Post.update_has_children_flag_for(parent_id)
Post.update_has_children_flag_for(parent_id_was)
else
Post.update_has_children_flag_for(parent_id)
end
end
def give_favorites_to_parent
return if parent.nil?
favorited_user_ids.each do |user_id|
parent.add_favorite(user_id)
remove_favorite(user_id)
end
end
def delete_favorites
Favorite.destroy_for_post(self)
end
end
module DeletionMethods
def delete!
Post.transaction do
give_favorites_to_parent
update_children_on_destroy
delete_favorites
decrement_tag_post_counts
update_attribute(:is_deleted, true)
update_parent_on_destroy
tag_array.each {|x| expire_cache(x)}
end
end
def undelete!
update_attribute(:is_deleted, false)
tag_array.each {|x| expire_cache(x)}
update_parent_on_save
end
end
module VersionMethods
def create_version
if created_at == updated_at
versions.create(
:rating => rating,
:source => source,
:add_tags => tag_string,
:parent_id => parent_id
)
else
versions.create(
:rating => rating_changed? ? rating : nil,
:source => source_changed? ? source : nil,
:add_tags => (tag_array - tag_array_was).join(" "),
:del_tags => (tag_array_was - tag_array).join(" "),
:parent_id => parent_id_changed? ? parent_id : nil
)
end
end
def revert_to(target)
base_tags = []
base_rating = "q"
base_source = nil
base_parent_id = nil
versions.each do |version|
if version.id <= target.id
base_tags += version.add_tag_array
base_tags -= version.del_tag_array
base_rating = version.rating if version.rating
base_source = version.source if version.source
base_parent_id = version.parent_id if version.parent_id
end
end
self.tag_string = base_tags.sort.join(" ")
self.rating = base_rating
self.source = base_source
self.parent_id = base_parent_id
end
def revert_to!(target)
revert_to(target)
save!
end
end
include FileMethods
include ImageMethods
include ApprovalMethods
include PresenterMethods
include TagMethods
include FavoriteMethods
include UploaderMethods
include PoolMethods
extend SearchMethods
include VoteMethods
extend CountMethods
include CacheMethods
include ParentMethods
include DeletionMethods
include VersionMethods
def reload(options = nil)
super
reset_tag_array_cache
end
end
Post.connection.extend(PostgresExtensions)