app/models/site.rb
# frozen_string_literal: true
# == Schema Information
#
# Table name: sites
#
# id :integer not null, primary key
# deleted_at :datetime
# description :text
# image_content_type :string
# image_file_name :string
# image_file_size :bigint
# image_updated_at :datetime
# latitude :decimal(9, 6)
# longitude :decimal(9, 6)
# name :string not null
# notes :text
# rails_tz :string(255)
# tzinfo_tz :string(255)
# created_at :datetime
# updated_at :datetime
# creator_id :integer not null
# deleter_id :integer
# region_id :integer
# updater_id :integer
#
# Indexes
#
# index_sites_on_creator_id (creator_id)
# index_sites_on_deleter_id (deleter_id)
# index_sites_on_updater_id (updater_id)
#
# Foreign Keys
#
# fk_rails_... (region_id => regions.id)
# sites_creator_id_fk (creator_id => users.id)
# sites_deleter_id_fk (deleter_id => users.id)
# sites_updater_id_fk (updater_id => users.id)
#
class Site < ApplicationRecord
# ensures timezones are handled consistently
include TimeZoneAttribute
# relations
has_and_belongs_to_many :projects, -> { distinct }
has_many :audio_recordings, inverse_of: :site
belongs_to :region, foreign_key: :region_id, inverse_of: :sites, optional: true
belongs_to :creator, class_name: 'User', foreign_key: :creator_id, inverse_of: :created_sites
belongs_to :updater, class_name: 'User', foreign_key: :updater_id, inverse_of: :updated_sites, optional: true
belongs_to :deleter, class_name: 'User', foreign_key: :deleter_id, inverse_of: :deleted_sites, optional: true
has_attached_file :image,
styles: {
span4: '300x300#',
span3: '220x220#',
span2: '140x140#',
span1: '60x60#',
spanhalf: '30x30#'
},
default_url: '/images/site/site_:style.png'
LATITUDE_MIN = -90
LATITUDE_MAX = 90
LONGITUDE_MIN = -180
LONGITUDE_MAX = 180
# See https://en.wikipedia.org/wiki/Decimal_degrees
# (neighborhood, street, about 333m)
JITTER_RANGE = 0.003
# add deleted_at and deleter_id
acts_as_paranoid
validates_as_paranoid
# association validations
#validates_associated :creator
# attribute validations
validates :name, presence: true, length: { minimum: 2 }
# between -90 and 90 degrees
validates :latitude, numericality: {
only_integer: false,
greater_than_or_equal_to: Site::LATITUDE_MIN,
less_than_or_equal_to: Site::LATITUDE_MAX,
message: "%<value>s must be greater than or equal to #{Site::LATITUDE_MIN} and less than or equal to #{Site::LATITUDE_MAX}"
}, allow_nil: true
# -180 and 180 degrees
validates :longitude, numericality: {
only_integer: false,
greater_than_or_equal_to: Site::LONGITUDE_MIN,
less_than_or_equal_to: Site::LONGITUDE_MAX,
message: "%<value>s must be greater than or equal to #{Site::LONGITUDE_MIN} and less than or equal to #{Site::LONGITUDE_MAX}"
}, allow_nil: true
validates_attachment_content_type :image,
content_type: %r{^image/(jpg|jpeg|pjpeg|png|x-png|gif)$},
message: 'file type %<value>s is not allowed (only jpeg/png/gif images)'
# commonly used queries
#scope :specified_sites, lambda { |site_ids| where('id in (:ids)', { :ids => site_ids } ) }
#scope :sites_in_project, lambda { |project_ids| where(Project.specified_projects, { :ids => project_ids } ) }
#scope :site_projects, lambda{ |project_ids| includes(:projects).where(:projects => {:id => project_ids} ) }
# get's a file system safe ([-A-Za-z0-9_]) version of the site name
# @return [String]
def safe_name
name
.gsub("'", '')
.gsub(/[^-_A-Za-z0-9]+/, '-')
.delete_prefix('-')
.delete_suffix('-')
.squeeze('-')
end
# The same as `safe_name` but appends site.id to ensure a unique name
# @return [String]
def unique_safe_name
"#{safe_name}_#{id}"
end
def get_bookmark
Bookmark.where(audio_recording: audio_recordings).order(:updated_at).first
end
def most_recent_recording
audio_recordings.order(recorded_date: :desc).first
end
def get_bookmark_or_recording
bookmark = get_bookmark
recording = most_recent_recording
if !bookmark.blank?
{
audio_recording: bookmark.audio_recording,
start_offset_seconds: bookmark.offset_seconds,
source: :bookmark
}
elsif !recording.blank?
{
audio_recording: recording,
start_offset_seconds: nil,
source: :audio_recording
}
end
end
renders_markdown_for :description
def custom_latitude
value = read_attribute(:latitude)
if location_obfuscated && !value.blank?
Site.add_location_jitter(value, Site::LATITUDE_MIN, Site::LATITUDE_MAX, location_jitter_seed)
else
value
end
end
def custom_longitude
value = read_attribute(:longitude)
if location_obfuscated && !value.blank?
Site.add_location_jitter(value, Site::LONGITUDE_MIN, Site::LONGITUDE_MAX, location_jitter_seed)
else
value
end
end
def location_obfuscated
return @location_obfuscated if defined?(@location_obfuscated)
if projects.empty?
@location_obfuscated = true
return @location_obfuscated
end
Access::Core.check_orphan_site!(self)
is_owner = Access::Core.can_any?(Current.user, :owner, projects)
# obfuscate if level is less than owner
@location_obfuscated = !is_owner
@location_obfuscated
end
def location_jitter_seed
# random numbers for jitters will be stable for given coordinates
# but changing coordinates will change the jitter
(((latitude || 0) * 1e12) + ((longitude || 0) * 1e6)).to_i
end
def self.add_location_jitter(value, min, max, seed)
# create a stable jitter by using the given seed
# but ensure a different seed is used for lat/long by incorporating the max
generator = Random.new(seed * max)
# sample once
random = generator.rand
# partition random over 0.5 to get a range of [-0.5, 0.5]
# this allows us to spread either side of the target value
random -= 0.5
# calculate the actual jitter
jitter = Site::JITTER_RANGE * random
# add in an exclusion buffer
# the addition pushes the jitter away from the mid point
# we push out by 10% of the jitter range, so for 0.003° ≈ 333m the push range is +- 33m
jitter += ((jitter >= 0 ? 1 : -1) * (Site::JITTER_RANGE * 0.1))
# finally augment the input value
# rounding just to produce a neat value
modified_value = (value + jitter).round(5)
# ensure modified value stays within lat/long valid ranges
modified_value = max if modified_value > max
modified_value = min if modified_value < min
modified_value
end
# Define filter api settings
def self.filter_settings
{
valid_fields: [:id, :name, :description, :notes, :creator_id,
:created_at, :updater_id, :updated_at, :deleter_id, :deleted_at, :region_id],
render_fields: [:id, :name, :description, :notes, :creator_id,
:created_at, :updater_id, :updated_at, :deleter_id, :deleted_at,
:region_id, :custom_latitude, :custom_longitude, :location_obfuscated],
text_fields: [:description, :name],
custom_fields: lambda { |item, _user|
# item can be nil or a new record
is_new_record = item.nil? || item.new_record?
fresh_site = is_new_record ? nil : Site.find(item.id)
site_hash = {}
unless fresh_site.nil?
site_hash = {
project_ids: fresh_site.projects.pluck(:id).flatten,
timezone_information: fresh_site.timezone,
image_urls: Api::Image.image_urls(fresh_site.image),
**item.render_markdown_for_api_for(:description)
}
end
[item, site_hash]
},
custom_fields2: {
custom_latitude: {
query_attributes: [:latitude, :id],
transform: ->(item) { item&.custom_latitude },
arel: nil,
type: nil
},
custom_longitude: {
query_attributes: [:longitude, :id],
transform: ->(item) { item&.custom_longitude },
arel: nil,
type: nil
},
location_obfuscated: {
query_attributes: [:id],
transform: ->(item) { item&.location_obfuscated },
arel: nil,
type: nil
}
},
new_spec_fields: lambda { |user| # rubocop:disable Lint/UnusedBlockArgument
{
longitude: nil,
latitude: nil,
notes: nil,
image: nil,
tzinfo_tz: nil,
rails_tz: nil
}
},
controller: :sites,
action: :filter,
defaults: {
order_by: :name,
direction: :asc
},
valid_associations: [
{
join: Arel::Table.new(:projects_sites),
on: Site.arel_table[:id].eq(Arel::Table.new(:projects_sites)[:site_id]),
available: false,
associations: [
{
join: Project,
on: Arel::Table.new(:projects_sites)[:project_id].eq(Project.arel_table[:id]),
available: true
}
]
},
{
join: Region,
on: Site.arel_table[:region_id].eq(Region.arel_table[:id]),
available: true
},
{
join: AudioRecording,
on: AudioRecording.arel_table[:site_id].eq(Site.arel_table[:id]),
available: true
}
]
}
end
def self.schema
{
type: 'object',
additionalProperties: false,
properties: {
id: { '$ref' => '#/components/schemas/id', readOnly: true },
name: { type: 'string' },
**Api::Schema.rendered_markdown(:description),
**Api::Schema.all_user_stamps,
#notes: { type: 'object' }, # TODO: https://github.com/QutEcoacoustics/baw-server/issues/467
notes: { type: 'string' },
project_ids: { type: 'array', items: { '$ref' => '#/components/schemas/id' } },
location_obfuscated: { type: 'boolean' },
custom_latitude: { type: ['number', 'null'], minimum: -90, maximum: 90 },
custom_longitude: { type: ['number', 'null'], minimum: -180, maximum: 180 },
timezone_information: Api::Schema.timezone_information,
image_urls: Api::Schema.image_urls,
region_id: { '$ref' => '#/components/schemas/nullableId' }
},
required: [
:id, :name, :description, :description_html, :description_html_tagline,
:creator_id, :created_at, :updater_id, :updated_at, :deleter_id,
:deleted_at, :notes, :project_ids, :location_obfuscated, :custom_latitude,
:custom_longitude, :timezone_information, :image_urls, :region_id
]
}.freeze
end
end