Files
nairobi/app/models/user.rb
Javi Martín 0214184b2d Remove investment supports query optimizations
In the previous commit I mentioned:

> If I'm right, the `investment_votes` instance variable only exists to
> avoid several database queries to get whether the current user has
> supported each of the investments.
>
> However, that doesn't make much sense when only one investment is
> shown.

Now let's discuss the case when there are several investments, like in
the investments index:

* There are 10 investments per page by default
* Each query takes less than a millisecond
* We still make a query per investment to check whether the current user
  voted in a different group
* AFAIK, there have been no performance tests showing these
  optimizations make the request to the investments index significantly
  faster
* These optimizations make the code way more complex than it is without
  them

Considering all these points, I'm removing the optimizations. I'm fine
with adding `includes` calls to preload records and avoid N+1 queries
even if there are no performance tests showing they make the application
faster because the effect on the code complexity is negligible. But
that's not the case here.

Note we're using `defined?` instead of the `||=` operator because the
`||=` operator will not serve its purpose when the result of the
operation returns `false`.
2021-06-14 14:41:57 +02:00

430 lines
12 KiB
Ruby

class User < ApplicationRecord
include Verification
devise :database_authenticatable, :registerable, :confirmable, :recoverable, :rememberable,
:trackable, :validatable, :omniauthable, :password_expirable, :secure_validatable,
authentication_keys: [:login]
acts_as_voter
acts_as_paranoid column: :hidden_at
include ActsAsParanoidAliases
include Graphqlable
has_one :administrator
has_one :moderator
has_one :valuator
has_one :manager
has_one :sdg_manager, class_name: "SDG::Manager", dependent: :destroy
has_one :poll_officer, class_name: "Poll::Officer"
has_one :organization
has_one :lock
has_many :flags
has_many :identities, dependent: :destroy
has_many :debates, -> { with_hidden }, foreign_key: :author_id, inverse_of: :author
has_many :proposals, -> { with_hidden }, foreign_key: :author_id, inverse_of: :author
has_many :activities
has_many :budget_investments, -> { with_hidden },
class_name: "Budget::Investment",
foreign_key: :author_id,
inverse_of: :author
has_many :comments, -> { with_hidden }, inverse_of: :user
has_many :failed_census_calls
has_many :notifications
has_many :direct_messages_sent,
class_name: "DirectMessage",
foreign_key: :sender_id,
inverse_of: :sender
has_many :direct_messages_received,
class_name: "DirectMessage",
foreign_key: :receiver_id,
inverse_of: :receiver
has_many :legislation_answers, class_name: "Legislation::Answer", dependent: :destroy, inverse_of: :user
has_many :follows
has_many :legislation_annotations,
class_name: "Legislation::Annotation",
foreign_key: :author_id,
inverse_of: :author
has_many :legislation_proposals,
class_name: "Legislation::Proposal",
foreign_key: :author_id,
inverse_of: :author
has_many :legislation_questions,
class_name: "Legislation::Question",
foreign_key: :author_id,
inverse_of: :author
has_many :polls, foreign_key: :author_id, inverse_of: :author
has_many :poll_answers,
class_name: "Poll::Answer",
foreign_key: :author_id,
inverse_of: :author
has_many :poll_pair_answers,
class_name: "Poll::PairAnswer",
foreign_key: :author_id,
inverse_of: :author
has_many :poll_partial_results,
class_name: "Poll::PartialResult",
foreign_key: :author_id,
inverse_of: :author
has_many :poll_questions,
class_name: "Poll::Question",
foreign_key: :author_id,
inverse_of: :author
has_many :poll_recounts,
class_name: "Poll::Recount",
foreign_key: :author_id,
inverse_of: :author
has_many :topics, foreign_key: :author_id, inverse_of: :author
belongs_to :geozone
validates :username, presence: true, if: :username_required?
validates :username, uniqueness: { scope: :registering_with_oauth }, if: :username_required?
validates :document_number, uniqueness: { scope: :document_type }, allow_nil: true
validate :validate_username_length
validates :official_level, inclusion: { in: 0..5 }
validates :terms_of_service, acceptance: { allow_nil: false }, on: :create
validates_associated :organization, message: false
accepts_nested_attributes_for :organization, update_only: true
attr_accessor :skip_password_validation, :use_redeemable_code, :login
scope :administrators, -> { joins(:administrator) }
scope :moderators, -> { joins(:moderator) }
scope :organizations, -> { joins(:organization) }
scope :sdg_managers, -> { joins(:sdg_manager) }
scope :officials, -> { where("official_level > 0") }
scope :male, -> { where(gender: "male") }
scope :female, -> { where(gender: "female") }
scope :newsletter, -> { where(newsletter: true) }
scope :for_render, -> { includes(:organization) }
scope :by_document, ->(document_type, document_number) do
where(document_type: document_type, document_number: document_number)
end
scope :email_digest, -> { where(email_digest: true) }
scope :active, -> { where(erased_at: nil) }
scope :erased, -> { where.not(erased_at: nil) }
scope :public_for_api, -> { all }
scope :by_authors, ->(author_ids) { where(id: author_ids) }
scope :by_comments, ->(commentables) do
joins(:comments).where("comments.commentable": commentables).distinct
end
scope :by_username_email_or_document_number, ->(search_string) do
search = "%#{search_string.strip}%"
where("username ILIKE ? OR email ILIKE ? OR document_number ILIKE ?", search, search, search)
end
scope :between_ages, ->(from, to) do
where(
"date_of_birth > ? AND date_of_birth < ?",
to.years.ago.beginning_of_year,
from.years.ago.end_of_year
)
end
before_validation :clean_document_number
# Get the existing user by email if the provider gives us a verified email.
def self.first_or_initialize_for_oauth(auth)
oauth_email = auth.info.email
oauth_email_confirmed = oauth_email.present? && (auth.info.verified || auth.info.verified_email)
oauth_user = User.find_by(email: oauth_email) if oauth_email_confirmed
oauth_user || User.new(
username: auth.info.name || auth.uid,
email: oauth_email,
oauth_email: oauth_email,
password: Devise.friendly_token[0, 20],
terms_of_service: "1",
confirmed_at: oauth_email_confirmed ? DateTime.current : nil
)
end
def name
organization? ? organization.name : username
end
def debate_votes(debates)
voted = votes.for_debates(Array(debates).map(&:id))
voted.each_with_object({}) { |v, h| h[v.votable_id] = v.value }
end
def proposal_votes(proposals)
voted = votes.for_proposals(Array(proposals).map(&:id))
voted.each_with_object({}) { |v, h| h[v.votable_id] = v.value }
end
def legislation_proposal_votes(proposals)
voted = votes.for_legislation_proposals(proposals)
voted.each_with_object({}) { |v, h| h[v.votable_id] = v.value }
end
def comment_flags(comments)
comment_flags = flags.for_comments(comments)
comment_flags.each_with_object({}) { |f, h| h[f.flaggable_id] = true }
end
def voted_in_group?(group)
votes.for_budget_investments(Budget::Investment.where(group: group)).exists?
end
def headings_voted_within_group(group)
Budget::Heading.where(id: voted_investments.by_group(group).pluck(:heading_id))
end
def voted_investments
Budget::Investment.where(id: votes.for_budget_investments.pluck(:votable_id))
end
def administrator?
administrator.present?
end
def moderator?
moderator.present?
end
def valuator?
valuator.present?
end
def manager?
manager.present?
end
def sdg_manager?
sdg_manager.present?
end
def poll_officer?
poll_officer.present?
end
def organization?
organization.present?
end
def verified_organization?
organization&.verified?
end
def official?
official_level && official_level > 0
end
def add_official_position!(position, level)
return if position.blank? || level.blank?
update! official_position: position, official_level: level.to_i
end
def remove_official_position!
update! official_position: nil, official_level: 0
end
def has_official_email?
domain = Setting["email_domain_for_officials"]
email.present? && ((email.end_with? "@#{domain}") || (email.end_with? ".#{domain}"))
end
def display_official_position_badge?
return true if official_level > 1
official_position_badge? && official_level == 1
end
def block
hide
Debate.hide_all debate_ids
Comment.hide_all comment_ids
Proposal.hide_all proposal_ids
Budget::Investment.hide_all budget_investment_ids
ProposalNotification.hide_all ProposalNotification.where(author_id: id).pluck(:id)
end
def full_restore
ActiveRecord::Base.transaction do
Debate.restore_all debates.where("hidden_at >= ?", hidden_at)
Comment.restore_all comments.where("hidden_at >= ?", hidden_at)
Proposal.restore_all proposals.where("hidden_at >= ?", hidden_at)
Budget::Investment.restore_all budget_investments.where("hidden_at >= ?", hidden_at)
ProposalNotification.restore_all(
ProposalNotification.only_hidden.where("hidden_at >= ?", hidden_at).where(author_id: id)
)
restore
end
end
def erase(erase_reason = nil)
update!(
erased_at: Time.current,
erase_reason: erase_reason,
username: nil,
email: nil,
unconfirmed_email: nil,
phone_number: nil,
encrypted_password: "",
confirmation_token: nil,
reset_password_token: nil,
email_verification_token: nil,
confirmed_phone: nil,
unconfirmed_phone: nil
)
identities.destroy_all
end
def erased?
erased_at.present?
end
def take_votes_if_erased_document(document_number, document_type)
erased_user = User.erased.find_by(document_number: document_number,
document_type: document_type)
if erased_user.present?
take_votes_from(erased_user)
erased_user.update!(document_number: nil, document_type: nil)
end
end
def take_votes_from(other_user)
return if other_user.blank?
Poll::Voter.where(user_id: other_user.id).update_all(user_id: id)
Budget::Ballot.where(user_id: other_user.id).update_all(user_id: id)
Vote.where("voter_id = ? AND voter_type = ?", other_user.id, "User").update_all(voter_id: id)
data_log = "id: #{other_user.id} - #{Time.current.strftime("%Y-%m-%d %H:%M:%S")}"
update!(former_users_data_log: "#{former_users_data_log} | #{data_log}")
end
def locked?
Lock.find_or_create_by!(user: self).locked?
end
def self.search(term)
return none if term.blank?
search = term.strip
where("email = ? OR username ILIKE ?", search, "%#{search}%")
end
def self.username_max_length
@username_max_length ||= columns.find { |c| c.name == "username" }.limit || 60
end
def self.minimum_required_age
(Setting["min_age_to_participate"] || 16).to_i
end
def show_welcome_screen?
verification = Setting["feature.user.skip_verification"].present? ? true : unverified?
sign_in_count == 1 && verification && !organization && !administrator?
end
def password_required?
return false if skip_password_validation
super
end
def username_required?
!organization? && !erased?
end
def email_required?
!erased? && unverified?
end
def locale
self[:locale] ||= I18n.default_locale.to_s
end
def confirmation_required?
super && !registering_with_oauth
end
def send_oauth_confirmation_instructions
if oauth_email != email
update(confirmed_at: nil)
send_confirmation_instructions
end
update(oauth_email: nil) if oauth_email.present?
end
def name_and_email
"#{name} (#{email})"
end
def age
Age.in_years(date_of_birth)
end
def save_requiring_finish_signup
begin
self.registering_with_oauth = true
save!(validate: false)
# Devise puts unique constraints for the email the db, so we must detect & handle that
rescue ActiveRecord::RecordNotUnique
self.email = nil
save!(validate: false)
end
true
end
def ability
@ability ||= Ability.new(self)
end
delegate :can?, :cannot?, to: :ability
def public_proposals
public_activity? ? proposals : User.none
end
def public_debates
public_activity? ? debates : User.none
end
def public_comments
public_activity? ? comments : User.none
end
# overwritting of Devise method to allow login using email OR username
def self.find_for_database_authentication(warden_conditions)
conditions = warden_conditions.dup
login = conditions.delete(:login)
where(conditions.to_hash).find_by(["lower(email) = ?", login.downcase]) ||
where(conditions.to_hash).find_by(["username = ?", login])
end
def self.find_by_manager_login(manager_login)
find_by(id: manager_login.split("_").last)
end
def interests
followables = follows.map(&:followable)
followables.compact.map { |followable| followable.tags.map(&:name) }.flatten.compact.uniq
end
def send_devise_notification(notification, *args)
devise_mailer.send(notification, self, *args).deliver_later
end
private
def clean_document_number
return unless document_number.present?
self.document_number = document_number.gsub(/[^a-z0-9]+/i, "").upcase
end
def validate_username_length
validator = ActiveModel::Validations::LengthValidator.new(
attributes: :username,
maximum: User.username_max_length)
validator.validate(self)
end
end