mirror of
https://github.com/Growstuff/growstuff.git
synced 2026-10-31 03:09:06 -04:00
* Remove unused bluecloth gem Last released in 2011. It was only required, never called; kramdown renders the markdown. * Add specs for Tilt markdown rendering and posts markdown partial * Remove rails_12factor gem Last released in 2014. Rails 5+ does this via RAILS_SERVE_STATIC_FILES and RAILS_LOG_TO_STDOUT, which production.rb already reads. * Remove memcachier gem from Gemfile and Gemfile.lock * Remove unused webrat gem Last released in 2011. Nothing requires it; the specs use Rails' assert_select and Capybara matchers. Only generator comments mention it. * Remove crowdin-cli gem Last released in 2018. Crowdin now ships its own standalone CLI, which reads crowdin.yml and doesn't need the gem. Also drops the dependencies only it used (rest-client, gli, crowdin-api and friends). * Removes the crowdin-cli gem (last released 2018), the dependencies only it needed, and crowdin.yml. Crowdin was never set up: the config still has a placeholder project ID and nothing else in the repo mentions it. Assisted by Claude * Remove newsletter functionality and associated views and tests * Remove newsletter functionality and related code across the application * keep the column * Remove stale test step * add back newsletter * Add migration to update newsletter column comment in members table --------- Co-authored-by: Daniel O'Connor <daniel.oconnor@gmail.com>
277 lines
9.8 KiB
Ruby
277 lines
9.8 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class Member < ApplicationRecord
|
|
include Discard::Model
|
|
|
|
acts_as_messageable # messages can be sent to this model
|
|
include Geocodable
|
|
include MemberFlickr
|
|
|
|
extend FriendlyId
|
|
|
|
friendly_id :login_name, use: %i(slugged finders)
|
|
|
|
#
|
|
# Relationships
|
|
has_many :posts, foreign_key: 'author_id', dependent: :destroy, inverse_of: :author
|
|
has_many :comments, foreign_key: 'author_id', dependent: :destroy, inverse_of: :author
|
|
has_many :forums, foreign_key: 'owner_id', dependent: :nullify, inverse_of: :owner
|
|
has_many :gardens, foreign_key: 'owner_id', dependent: :destroy, inverse_of: :owner
|
|
# Where they raise seedlings before planting them out.
|
|
has_one :nursery, -> { nurseries }, class_name: 'Garden', foreign_key: 'owner_id', dependent: nil, inverse_of: :owner
|
|
has_many :plantings, foreign_key: 'owner_id', dependent: :destroy, inverse_of: :owner
|
|
has_many :seeds, foreign_key: 'owner_id', dependent: :destroy, inverse_of: :owner
|
|
has_many :harvests, foreign_key: 'owner_id', dependent: :destroy, inverse_of: :owner
|
|
has_many :jobs, foreign_key: 'owner_id', dependent: :destroy, inverse_of: :owner
|
|
has_and_belongs_to_many :roles
|
|
has_many :notifications, foreign_key: 'recipient_id', inverse_of: :recipient
|
|
has_many :sent_notifications, foreign_key: 'sender_id', inverse_of: :sender, class_name: "Notification"
|
|
has_many :authentications, dependent: :destroy
|
|
has_one :api_token, -> { where(provider: 'api') }, class_name: 'Authentication', dependent: :destroy
|
|
|
|
def api_token?
|
|
api_token.present?
|
|
end
|
|
|
|
def regenerate_api_token
|
|
destroy_api_token
|
|
create_api_token(
|
|
provider: 'api',
|
|
uid: id,
|
|
token: SecureRandom.hex(16)
|
|
)
|
|
end
|
|
|
|
def destroy_api_token
|
|
api_token.destroy if api_token?
|
|
end
|
|
has_many :photos, inverse_of: :owner
|
|
has_many :likes, dependent: :destroy
|
|
|
|
#
|
|
# Following other members
|
|
has_many :follows, class_name: "Follow", foreign_key: "follower_id", dependent: :destroy,
|
|
inverse_of: :follower
|
|
has_many :inverse_follows, class_name: "Follow", foreign_key: "followed_id",
|
|
dependent: :destroy, inverse_of: :followed
|
|
has_many :followed, through: :follows
|
|
has_many :followers, through: :inverse_follows, source: :follower
|
|
|
|
# Crops they have said they don't want suggested
|
|
has_many :crop_dismissals, dependent: :destroy
|
|
has_many :dismissed_crops, through: :crop_dismissals, source: :crop
|
|
has_many :member_dismissals, dependent: :destroy
|
|
has_many :job_idea_dismissals, dependent: :destroy
|
|
|
|
#
|
|
# Blocking other members
|
|
has_many :blocks, class_name: "Block", foreign_key: "blocker_id", dependent: :destroy,
|
|
inverse_of: :blocker
|
|
has_many :inverse_blocks, class_name: "Block", foreign_key: "blocked_id",
|
|
dependent: :destroy, inverse_of: :blocked
|
|
has_many :blocked_members, through: :blocks, source: :blocked
|
|
has_many :blockers, through: :inverse_blocks, source: :blocker
|
|
|
|
#
|
|
# Global data records this member created
|
|
has_many :requested_crops, class_name: 'Crop', foreign_key: 'requester_id', dependent: :nullify,
|
|
inverse_of: :requester
|
|
has_many :created_crops, class_name: 'Crop', foreign_key: 'creator_id', dependent: :nullify,
|
|
inverse_of: :creator
|
|
has_many :created_alternate_names, class_name: 'AlternateName', foreign_key: 'creator_id', inverse_of: :creator
|
|
has_many :created_scientific_names, class_name: 'ScientificName', foreign_key: 'creator_id', inverse_of: :creator
|
|
has_many :created_crop_job_templates, class_name: 'CropJobTemplate', foreign_key: 'creator_id', dependent: :nullify,
|
|
inverse_of: :creator
|
|
|
|
#
|
|
# Scopes
|
|
scope :confirmed, -> { where.not(confirmed_at: nil) }
|
|
scope :located, -> { where.not(location: '').where.not(latitude: nil).where.not(longitude: nil) }
|
|
scope :recently_signed_in, -> { reorder(updated_at: :desc) }
|
|
scope :recently_joined, -> { reorder(confirmed_at: :desc) }
|
|
scope :interesting, -> { confirmed.located.recently_signed_in.has_plantings }
|
|
scope :has_plantings, -> { joins(:plantings).group("members.id") }
|
|
scope :wants_reminders, -> { where(send_planting_reminder: true) }
|
|
scope :wants_harvest_reminders, -> { where(send_harvest_reminder: true) }
|
|
|
|
# Include default devise modules. Others available are:
|
|
# :token_authenticatable, :confirmable,
|
|
# :lockable, :timeoutable and :omniauthable
|
|
devise :database_authenticatable, :registerable,
|
|
:recoverable, :rememberable, :trackable, :validatable,
|
|
:confirmable, :lockable, :timeoutable, :omniauthable
|
|
|
|
# discarded (deleted) member cannot log in
|
|
def active_for_authentication?
|
|
super && !discarded?
|
|
end
|
|
|
|
# When they first opened a feature, from settings['features_seen'], or nil.
|
|
def feature_first_seen_at(feature)
|
|
seen = settings.dig('features_seen', feature.to_s)
|
|
Time.zone.parse(seen) if seen
|
|
end
|
|
|
|
# Records the first time only; later visits leave it alone.
|
|
def feature_seen!(feature)
|
|
return if feature_first_seen_at(feature)
|
|
|
|
seen = settings.fetch('features_seen', {}).merge(feature.to_s => Time.current.iso8601)
|
|
update_column(:settings, settings.merge('features_seen' => seen)) # rubocop:disable Rails/SkipsModelValidations
|
|
end
|
|
|
|
# Whether they've said they don't want a nursery, so we stop asking. Having
|
|
# one trumps it.
|
|
def nursery_declined?
|
|
settings['nursery_declined'] == true && nursery.nil?
|
|
end
|
|
|
|
def decline_nursery!
|
|
update_column(:settings, settings.merge('nursery_declined' => true)) # rubocop:disable Rails/SkipsModelValidations
|
|
end
|
|
|
|
def accept_nursery!
|
|
update_column(:settings, settings.except('nursery_declined')) # rubocop:disable Rails/SkipsModelValidations
|
|
end
|
|
|
|
# set up geocoding
|
|
geocoded_by :location
|
|
|
|
# Virtual attribute for authenticating by either username or email
|
|
# This is in addition to a real persisted field like 'username'
|
|
attr_accessor :login
|
|
|
|
#
|
|
# Validations
|
|
# Requires acceptance of the Terms of Service
|
|
validates :tos_agreement, acceptance: { allow_nil: true, accept: true }
|
|
validates :login_name,
|
|
length: {
|
|
minimum: 2, maximum: 25, message: :login_name_length
|
|
},
|
|
exclusion: {
|
|
in: %w(growstuff admin moderator staff nearby), message: :login_name_reserved
|
|
},
|
|
format: {
|
|
with: /\A\w+\z/, message: :login_name_format
|
|
},
|
|
uniqueness: {
|
|
case_sensitive: false
|
|
}
|
|
validates :website_url, format: { with: %r{\Ahttps?://}, message: :url_format }, allow_blank: true
|
|
validates :other_url, format: { with: %r{\Ahttps?://}, message: :url_format }, allow_blank: true
|
|
validates :instagram_handle, :facebook_handle, :bluesky_handle,
|
|
format: { without: %r{\Ahttps?://|/}, message: :handle_format }, allow_blank: true
|
|
|
|
#
|
|
# Triggers
|
|
after_validation :geocode
|
|
after_validation :empty_unwanted_geocodes
|
|
|
|
# Give each new member a default garden
|
|
# we use find_or_create to avoid accidentally creating a second one,
|
|
# which can happen sometimes especially with FactoryBot associations
|
|
after_create { |member| Garden.create(name: "Garden", owner_id: member.id) }
|
|
|
|
# Their nursery, made the first time it's wanted, as most members never need
|
|
# one. The member may already have a garden called "Nursery", and garden names
|
|
# are unique per member, so ours is numbered until it's free.
|
|
def nursery!
|
|
return nursery if nursery
|
|
|
|
base = I18n.t('gardens.nursery_name')
|
|
taken = gardens.pluck(:name)
|
|
name = taken.include?(base) ? "#{base} #{(2..).find { |n| taken.exclude?("#{base} #{n}") }}" : base
|
|
accept_nursery!
|
|
create_nursery!(name:)
|
|
end
|
|
|
|
# allow login via either login_name or email address
|
|
def self.find_first_by_auth_conditions(warden_conditions)
|
|
conditions = warden_conditions.dup
|
|
login = conditions.delete(:login)
|
|
return where(conditions).login_name_or_email(login).first if login
|
|
|
|
find_by(conditions)
|
|
end
|
|
|
|
def to_s
|
|
discarded? ? 'deleted' : login_name
|
|
end
|
|
|
|
def to_param
|
|
slug
|
|
end
|
|
|
|
def mailboxer_email(_messageable)
|
|
send_notification_email ? email : false
|
|
end
|
|
|
|
def role?(role_sym)
|
|
roles.any? { |r| r.name.gsub(/\s+/, "_").underscore.to_sym == role_sym }
|
|
end
|
|
|
|
def auth(provider)
|
|
authentications.find_by(provider:)
|
|
end
|
|
|
|
def unread_count
|
|
@unread_count ||= receipts.where(is_read: false).count
|
|
end
|
|
|
|
def self.login_name_or_email(login)
|
|
where(["lower(login_name) = :value OR lower(email) = :value", { value: login.downcase }])
|
|
end
|
|
|
|
def self.case_insensitive_login_name(login)
|
|
where(["lower(login_name) = :value", { value: login.downcase }])
|
|
end
|
|
|
|
def self.nearest_to(place, limit = 50)
|
|
return Member.none if place.blank?
|
|
|
|
latitude, longitude = Geocoder.coordinates(place, params: { limit: 1 })
|
|
return Member.none unless latitude && longitude
|
|
|
|
relation = Member.located.near([latitude, longitude], 1000)
|
|
limit ? relation.limit(limit) : relation
|
|
end
|
|
|
|
def already_following?(member)
|
|
follows.exists?(followed_id: member.id)
|
|
end
|
|
|
|
def get_follow(member)
|
|
follows.find_by(followed_id: member.id) if already_following?(member)
|
|
end
|
|
|
|
def already_blocking?(member)
|
|
blocks.exists?(blocked_id: member.id)
|
|
end
|
|
|
|
def get_block(member)
|
|
blocks.find_by(blocked_id: member.id) if already_blocking?(member)
|
|
end
|
|
|
|
def has_activity?
|
|
gardens.not_nurseries.many? ||
|
|
plantings.exists? ||
|
|
harvests.exists? ||
|
|
seeds.exists? ||
|
|
photos.exists? ||
|
|
forums.exists? ||
|
|
jobs.exists? ||
|
|
posts.exists? ||
|
|
comments.exists? ||
|
|
requested_crops.exists? ||
|
|
created_crops.exists? ||
|
|
likes.exists? ||
|
|
created_alternate_names.exists? ||
|
|
created_scientific_names.exists? ||
|
|
follows.exists? ||
|
|
inverse_follows.exists? ||
|
|
blocks.exists? ||
|
|
inverse_blocks.exists?
|
|
end
|
|
end
|