diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..40103bc --- /dev/null +++ b/Gemfile @@ -0,0 +1,16 @@ +source 'http://rubygems.org' + +gem 'rails', '3.0.4' +gem 'mysql', '2.8.1' +gem 'authlogic' + +# Bundle gems for the local environment. Make sure to +# put test-only gems in this group so their generators +# and rake tasks are available in development mode: +group :development, :test do + gem 'rspec', '>= 2.0.0.beta.20' + gem 'rspec-rails', '>= 2.0.0.beta.20' + gem 'autotest' + gem 'webrat' + gem 'annotate' +end diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..9bfd9f1 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,104 @@ +GEM + remote: http://rubygems.org/ + specs: + ZenTest (4.5.0) + abstract (1.0.0) + actionmailer (3.0.4) + actionpack (= 3.0.4) + mail (~> 2.2.15) + actionpack (3.0.4) + activemodel (= 3.0.4) + activesupport (= 3.0.4) + builder (~> 2.1.2) + erubis (~> 2.6.6) + i18n (~> 0.4) + rack (~> 1.2.1) + rack-mount (~> 0.6.13) + rack-test (~> 0.5.7) + tzinfo (~> 0.3.23) + activemodel (3.0.4) + activesupport (= 3.0.4) + builder (~> 2.1.2) + i18n (~> 0.4) + activerecord (3.0.4) + activemodel (= 3.0.4) + activesupport (= 3.0.4) + arel (~> 2.0.2) + tzinfo (~> 0.3.23) + activeresource (3.0.4) + activemodel (= 3.0.4) + activesupport (= 3.0.4) + activesupport (3.0.4) + annotate (2.4.0) + arel (2.0.9) + authlogic (2.1.6) + activesupport + autotest (4.4.6) + ZenTest (>= 4.4.1) + builder (2.1.2) + diff-lcs (1.1.2) + erubis (2.6.6) + abstract (>= 1.0.0) + i18n (0.5.0) + mail (2.2.15) + activesupport (>= 2.3.6) + i18n (>= 0.4.0) + mime-types (~> 1.16) + treetop (~> 1.4.8) + mime-types (1.16) + mysql (2.8.1) + nokogiri (1.4.4) + polyglot (0.3.1) + rack (1.2.2) + rack-mount (0.6.14) + rack (>= 1.0.0) + rack-test (0.5.7) + rack (>= 1.0) + rails (3.0.4) + actionmailer (= 3.0.4) + actionpack (= 3.0.4) + activerecord (= 3.0.4) + activeresource (= 3.0.4) + activesupport (= 3.0.4) + bundler (~> 1.0) + railties (= 3.0.4) + railties (3.0.4) + actionpack (= 3.0.4) + activesupport (= 3.0.4) + rake (>= 0.8.7) + thor (~> 0.14.4) + rake (0.8.7) + rspec (2.5.0) + rspec-core (~> 2.5.0) + rspec-expectations (~> 2.5.0) + rspec-mocks (~> 2.5.0) + rspec-core (2.5.1) + rspec-expectations (2.5.0) + diff-lcs (~> 1.1.2) + rspec-mocks (2.5.0) + rspec-rails (2.5.0) + actionpack (~> 3.0) + activesupport (~> 3.0) + railties (~> 3.0) + rspec (~> 2.5.0) + thor (0.14.6) + treetop (1.4.9) + polyglot (>= 0.3.1) + tzinfo (0.3.25) + webrat (0.7.3) + nokogiri (>= 1.2.0) + rack (>= 1.0) + rack-test (>= 0.5.3) + +PLATFORMS + ruby + +DEPENDENCIES + annotate + authlogic + autotest + mysql (= 2.8.1) + rails (= 3.0.4) + rspec (>= 2.0.0.beta.20) + rspec-rails (>= 2.0.0.beta.20) + webrat diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..c6cc6a8 --- /dev/null +++ b/Rakefile @@ -0,0 +1,7 @@ +# Add your own tasks in files placed in lib/tasks ending in .rake, +# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. + +require File.expand_path('../config/application', __FILE__) +require 'rake' + +Thingspeak::Application.load_tasks diff --git a/app/controllers/api_keys_controller.rb b/app/controllers/api_keys_controller.rb new file mode 100644 index 0000000..0474623 --- /dev/null +++ b/app/controllers/api_keys_controller.rb @@ -0,0 +1,45 @@ +class ApiKeysController < ApplicationController + before_filter :require_user, :set_channels_menu + + def index + get_channel_data + @read_keys = ApiKey.find(:all, :conditions => { :channel_id => @channel.id, :user_id => current_user.id, :write_flag => 0 }) + end + + def destroy + @api_key = ApiKey.find_by_api_key(params[:api_key]) + @api_key.delete if @api_key.user_id == current_user.id + redirect_to :back + end + + def create + @channel = Channel.find(params[:channel_id]) + # make sure channel belongs to current user + check_permissions(@channel) + + @api_key = ApiKey.find(:first, :conditions => { :channel_id => @channel.id, :user_id => current_user.id, :write_flag => 1 } ) + + # if no api key found or read api key + if (@api_key.nil? or params[:write] == '0') + @api_key = ApiKey.new + @api_key.channel_id = @channel.id + @api_key.user_id = current_user.id + @api_key.write_flag = params[:write] + end + + # set new api key and save + @api_key.api_key = generate_api_key + @api_key.save + + # redirect + redirect_to channel_api_keys_path(@channel.id) and return + end + + def update + @api_key = ApiKey.find_by_api_key(params[:api_key][:api_key]) + + @api_key.note = params[:api_key][:note] + @api_key.save if current_user.id == @api_key.user_id + redirect_to channel_api_keys_path(@api_key.channel) + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb new file mode 100644 index 0000000..ffda231 --- /dev/null +++ b/app/controllers/application_controller.rb @@ -0,0 +1,166 @@ +class ApplicationController < ActionController::Base + # include all helpers for controllers + helper :all + # include these helper methods for views + helper_method :current_user_session, :current_user, :get_header_value + protect_from_forgery + before_filter :set_variables + + # set up some variables across the entire application + def set_variables + # hard code locale as english + I18n.locale = 'en' + # sets timezone for current user, all DateTime outputs will be automatically formatted + Time.zone = current_user.time_zone if current_user + end + + private + + def set_channels_menu + @menu = 'channels' + end + + def current_user_session + return @current_user_session if defined?(@current_user_session) + @current_user_session = UserSession.find + end + + def current_user + return @current_user if defined?(@current_user) + @current_user = current_user_session && current_user_session.record + end + + # check that user is logged in + def require_user + if current_user.nil? + redirect_to login_path + false + end + end + + def require_no_user + if current_user + store_location + redirect_to account_path + false + end + end + + def store_location + if params[:controller] != "user_sessions" + session[:return_to] = request.fullpath + end + end + + def redirect_back_or_default(default) + redirect_to(session[:return_to] || default) + session[:return_to] = nil + end + + def domain + u = request.url + begin + # the number 12 is the position at which to begin searching for '/', so we don't get the intitial '/' from http:// + u = u[0..u.index('/', 12)] + rescue + u += '/' + end + # uncomment the line below for https support in a production environment + #u = u.sub(/http:/, 'https:') if Rails.env == 'production' + return u + end + + # gets the api key + def get_userkey + return get_header_value('THINGSPEAKAPIKEY') || params[:key] || params[:api_key] || params[:apikey] + end + + # get specified header value + def get_header_value(name) + value = nil + for header in request.env + value = header[1] if (header[0].upcase.index(name.upcase)) + end + return value + end + + # gets the same data for showing or editing + def get_channel_data + @channel = Channel.find(params[:channel_id]) if params[:channel_id] + @channel = Channel.find(params[:id]) if @channel.nil? and params[:id] + @key = '' + # make sure channel belongs to current user + check_permissions(@channel) + + @api_key = ApiKey.find(:first, :conditions => { :channel_id => @channel.id, :user_id => current_user.id, :write_flag => 1 } ) + @key = @api_key.api_key if @api_key + end + + def check_permissions(channel) + render :text => t(:channel_permission) and return if (current_user.nil? || (channel.user_id != current_user.id)) + end + + # checks permission for channel using api_key + def channel_permission?(channel, api_key) + if channel.public_flag or (api_key and api_key.channel_id == channel.id) or (current_user and channel.user_id == current_user.id) + return true + else + return false + end + end + + # outputs error for bad channel + def bad_channel_xml + channel_unauthorized = Channel.new + channel_unauthorized.id = -1 + return channel_unauthorized.to_xml(:only => :id) + end + + # outputs error for bad feed + def bad_feed_xml + feed_unauthorized = Feed.new + feedl_unauthorized.id = -1 + return feed_unauthorized.to_xml(:only => :entry_id) + end + + # generates a database unique api key + def generate_api_key(size = 16) + alphanumerics = ('0'..'9').to_a + ('A'..'Z').to_a + k = (0..size).map {alphanumerics[Kernel.rand(36)]}.join + + # if key exists in database, regenerate key + k = generate_api_key if ApiKey.find_by_api_key(k) + + # output the key + return k + end + + # options: days = how many days ago, start = start date, end = end date, offset = timezone offset + def get_date_range(params) + # set timezone correctly + set_time_zone(params) + + start_date = Time.now - 1.day + end_date = Time.now + start_date = (Time.now - params[:days].to_i.days) if params[:days] + start_date = DateTime.strptime(params[:start]) if params[:start] + end_date = DateTime.strptime(params[:end]) if params[:end] + date_range = (start_date..end_date) + # only get a maximum of 30 days worth of data + date_range = (end_date - 30.days..end_date) if (end_date - start_date) > 30.days + + return date_range + end + + def set_time_zone(params) + # set timezone correctly + if params[:offset] + Time.zone = params[:offset].to_i + elsif current_user + Time.zone = current_user.time_zone + else + Time.zone = 0 + end + end + +end diff --git a/app/controllers/channels_controller.rb b/app/controllers/channels_controller.rb new file mode 100644 index 0000000..2cbc725 --- /dev/null +++ b/app/controllers/channels_controller.rb @@ -0,0 +1,125 @@ +class ChannelsController < ApplicationController + before_filter :require_user, :except => [ :show, :post_data ] + before_filter :set_channels_menu + protect_from_forgery :except => :post_data + + def index + @channels = current_user.channels + end + + def show + @channel = Channel.find(params[:id]) if params[:id] + + # if owner of channel + get_channel_data if current_user and @channel.user_id == current_user.id + end + + def edit + get_channel_data + end + + def update + @channel = Channel.find(params[:id]) + # make sure channel belongs to current user + check_permissions(@channel) + # protect against bots + render :text => '' and return if params[:userlogin].length > 0 + + @channel.update_attributes(params[:channel]) + @channel.name = "#{t(:channel_default_name)} #{@channel.id}" if params[:channel][:name].empty? + @channel.save + redirect_to channel_path(@channel.id) and return + end + + def create + # protect against bots + render :text => '' and return if params[:userlogin].length > 0 + + # get default name for field + @d = t(:channel_default_field) + + # add channel with defaults + @channel = Channel.new(:field1 => "#{@d}1") + @channel.user_id = current_user.id + @channel.save + + # now that the channel is saved, we can create the default name + @channel.name = "#{t(:channel_default_name)} #{@channel.id}" + @channel.save + + # create an api key for this channel + @api_key = ApiKey.new + @api_key.channel_id = @channel.id + @api_key.user_id = current_user.id + @api_key.write_flag = 1 + @api_key.api_key = generate_api_key + @api_key.save + + # redirect to edit the newly created channel + redirect_to edit_channel_path(@channel.id) + end + + def destroy + @channel = Channel.find(params[:id]) + # make sure channel belongs to current user + check_permissions(@channel) + + # do the delete + @channel.delete + redirect_to channels_path + end + + # response is '0' if failure, 'entry_id' if success + def post_data + status = '0' + feed = Feed.new + + api_key = ApiKey.find_by_api_key(get_userkey) + + # if write persmission, allow post + if (api_key && api_key.write_flag) + channel = Channel.find(api_key.channel_id) + + # update entry_id for channel and feed + entry_id = channel.last_entry_id.nil? ? 1 : channel.last_entry_id + 1 + channel.last_entry_id = entry_id + feed.entry_id = entry_id + + # try to get created_at datetime if appropriate + if params[:created_at] + begin + @feed.created_at = DateTime.parse(params[:created_at]) + # if invalid datetime, don't do anything--rails will set created_at + rescue + end + end + + # strip line feeds from end of parameters + params.each do |key, value| + params[key] = value.sub(/\\n$/, '').sub(/\\r$/, '') + end + + # set feed details + feed.channel_id = channel.id + feed.raw_data = params + feed.field1 = params[:field1] if params[:field1] + feed.field2 = params[:field2] if params[:field2] + feed.field3 = params[:field3] if params[:field3] + feed.field4 = params[:field4] if params[:field4] + feed.field5 = params[:field5] if params[:field5] + feed.field6 = params[:field6] if params[:field6] + feed.field7 = params[:field7] if params[:field7] + feed.field8 = params[:field8] if params[:field8] + feed.status = params[:status] if params[:status] + + if channel.save && feed.save + status = entry_id + end + end + + # output response code + render :text => '0', :status => 400 and return if status == '0' + render :text => status + end + +end diff --git a/app/controllers/charts_controller.rb b/app/controllers/charts_controller.rb new file mode 100644 index 0000000..0ab5b9a --- /dev/null +++ b/app/controllers/charts_controller.rb @@ -0,0 +1,78 @@ +class ChartsController < ApplicationController + + def index + set_channels_menu + @channel = Channel.find(params[:channel_id]) + @channel_id = params[:channel_id] + @domain = domain + + # default chart size + @width = default_width + @height = default_height + + check_permissions(@channel) + end + + def show + # allow these parameters when creating feed querystring + feed_params = ['key','days','start','end','round','timescale','average','median','sum'] + + # default chart size + @width = default_width + @height = default_height + + # add extra parameters to querystring + @qs = '' + params.each do |p| + @qs += "{p[0]}=#{p[1]}" if feed_params.include?(p[0]) + end + + # fix chart colors if necessary + params[:color] = fix_color(params[:color]) + params[:bgcolor] = fix_color(params[:bgcolor]) + + @domain = domain + render :layout => false + end + + # save chart options + def update + @channel = Channel.find(params[:channel_id]) + @status = 0 + + # check permissions + if @channel.user_id == current_user.id + + # save data + @channel["options#{params[:id]}"] = params[:options] + if @channel.save + @status = 1 + end + + end + + # return response: 1=success, 0=failure + render :json => @status.to_json + end + + private + + def default_width + 450 + end + + def default_height + 250 + end + + # fixes chart color if user forgets the leading '#' + def fix_color(color) + # check for 3 or 6 character hexadecimal value + if (color and color.match(/^([0-9]|[a-f]|[A-F]){3}(([0-9]|[a-f]|[A-F]){3})?$/)) + color = '#' + color + end + + return color + end + +end diff --git a/app/controllers/feed_controller.rb b/app/controllers/feed_controller.rb new file mode 100644 index 0000000..129de78 --- /dev/null +++ b/app/controllers/feed_controller.rb @@ -0,0 +1,501 @@ +class FeedController < ApplicationController + require 'csv' + + def index + channel = Channel.find(params[:channel_id]) + api_key = ApiKey.find_by_api_key(get_userkey) + @success = channel_permission?(channel, api_key) + + # check for access + if @success + # create options hash + channel_options = { :only => channel_select_data(channel) } + select_options = feed_select_data(channel) + + # get feed based on conditions + feeds = Feed.find( + :all, + :conditions => { :channel_id => channel.id, :created_at => get_date_range(params) }, + :select => select_options, + :order => 'created_at' + ) + + # if a feed has data + if !feeds.empty? + # convert to timescales if necessary + if timeparam_valid?(params[:timescale]) + feeds = feeds_into_timescales(feeds) + # convert to sums if necessary + elsif timeparam_valid?(params[:sum]) + feeds = feeds_into_sums(feeds) + # convert to averages if necessary + elsif timeparam_valid?(params[:average]) + feeds = feeds_into_averages(feeds) + # convert to medians if necessary + elsif timeparam_valid?(params[:median]) + feeds = feeds_into_medians(feeds) + end + end + + # set output correctly + if params[:format] == 'xml' + @channel_output = channel.to_xml(channel_options).sub('', '').strip + @feed_output = feeds.to_xml(:skip_instruct => true).gsub(/\n/, "\n ").chop.chop + elsif params[:format] == 'csv' + @feed_output = feeds + else + @channel_output = channel.to_json(channel_options).chop + @feed_output = feeds.to_json + end + + # else no access, set error code + else + if params[:format] == 'xml' + @channel_output = bad_channel_xml + else + @channel_output = '-1'.to_json + end + end + + # set callback for jsonp + @callback = params[:callback] if params[:callback] + + # set csv headers if necessary + @csv_headers = select_options if params[:format] == 'csv' + + # output proper http response if error + render :text => '-1', :status => 400 and return if !@success + + # output data in proper format + respond_to do |format| + format.html + format.json + format.xml + format.csv + end + end + + def show + @channel = Channel.find(params[:channel_id]) + @api_key = ApiKey.find_by_api_key(get_userkey) + output = '-1' + + # get most recent entry if necessary + params[:id] = @channel.last_entry_id if params[:id] == 'last' + + # set timezone correctly + set_time_zone(params) + + @feed = Feed.find( + :first, + :conditions => { :channel_id => @channel.id, :entry_id => params[:id] }, + :select => feed_select_data(@channel) + ) + @success = channel_permission?(@channel, @api_key) + + # check for access + if @success + # set output correctly + if params[:format] == 'xml' + output = @feed.to_xml + elsif params[:format] == 'csv' + @csv_headers = feed_select_data(@channel) + elsif (params[:format] == 'txt' or params[:format] == 'text') + output = add_prepend_append(@feed["field#{params[:field_id]}"]) + else + output = @feed.to_json + end + # else set error code + else + if params[:format] == 'xml' + output = bad_feed_xml + else + output = '-1'.to_json + end + end + + # output data in proper format + respond_to do |format| + format.html { render :json => output } + format.json { render :json => output, :callback => params[:callback] } + format.xml { render :xml => output } + format.csv + format.text { render :text => output } + end + end + + private + + # only output these fields for channel + def channel_select_data(channel) + only = [:name, :created_at, :updated_at, :id, :last_entry_id] + only += [:description] unless channel.description.blank? + only += [:latitude] unless channel.latitude.blank? + only += [:longitude] unless channel.longitude.blank? + only += [:elevation] unless channel.elevation.blank? + only += [:field1] unless channel.field1.blank? + only += [:field2] unless channel.field2.blank? + only += [:field3] unless channel.field3.blank? + only += [:field4] unless channel.field4.blank? + only += [:field5] unless channel.field5.blank? + only += [:field6] unless channel.field6.blank? + only += [:field7] unless channel.field7.blank? + only += [:field8] unless channel.field8.blank? + + return only + end + + # only output these fields for feed + def feed_select_data(channel) + only = [:created_at] + only += [:entry_id] unless timeparam_valid?(params[:timescale]) or timeparam_valid?(params[:average]) or timeparam_valid?(params[:median]) or timeparam_valid?(params[:sum]) + only += [:field1] unless channel.field1.blank? or (params[:field_id] and params[:field_id] != '1') + only += [:field2] unless channel.field2.blank? or (params[:field_id] and params[:field_id] != '2') + only += [:field3] unless channel.field3.blank? or (params[:field_id] and params[:field_id] != '3') + only += [:field4] unless channel.field4.blank? or (params[:field_id] and params[:field_id] != '4') + only += [:field5] unless channel.field5.blank? or (params[:field_id] and params[:field_id] != '5') + only += [:field6] unless channel.field6.blank? or (params[:field_id] and params[:field_id] != '6') + only += [:field7] unless channel.field7.blank? or (params[:field_id] and params[:field_id] != '7') + only += [:field8] unless channel.field8.blank? or (params[:field_id] and params[:field_id] != '8') + only += [:status] if params[:status] and params[:status].upcase == 'TRUE' + + return only + end + + # checks for valid timescale + def timeparam_valid?(timeparam) + valid_minutes = [10, 15, 20, 30, 60, 240, 720, 1440] + if timeparam and valid_minutes.include?(timeparam.to_i) + return true + else + return false + end + end + # slice feed into timescales + def feeds_into_timescales(feeds) + # convert timescale (minutes) into seconds + seconds = params[:timescale].to_i * 60 + # get floored time ranges + start_time = get_floored_time(feeds.first.created_at, seconds) + end_time = get_floored_time(feeds.last.created_at, seconds) + + # create empty array with appropriate size + timeslices = Array.new(((end_time - start_time) / seconds).floor) + + # create a blank clone of the first feed so that we only get the necessary attributes + empty_feed = create_empty_clone(feeds.first) + + # add feeds to array + feeds.each do |f| + i = ((f.created_at - start_time) / seconds).floor + f.created_at = start_time + i * seconds + timeslices[i] = f if timeslices[i].nil? + end + + # fill in empty array elements + timeslices.each_index do |i| + if timeslices[i].nil? + current_feed = empty_feed.clone + current_feed.created_at = (start_time + (i * seconds)) + timeslices[i] = current_feed + end + end + + return timeslices + end + + # slice feed into averages + def feeds_into_averages(feeds) + # convert timescale (minutes) into seconds + seconds = params[:average].to_i * 60 + # get floored time ranges + start_time = get_floored_time(feeds.first.created_at, seconds) + end_time = get_floored_time(feeds.last.created_at, seconds) + + # create empty array with appropriate size + timeslices = Array.new(((end_time - start_time) / seconds).floor) + + # create a blank clone of the first feed so that we only get the necessary attributes + empty_feed = create_empty_clone(feeds.first) + + # add feeds to array + feeds.each do |f| + i = ((f.created_at - start_time) / seconds).floor + f.created_at = start_time + i * seconds + # create multidimensional array + timeslices[i] = [] if timeslices[i].nil? + timeslices[i].push(f) + end + + # keep track of whether numbers use commas as decimals + comma_flag = false + + # fill in array + timeslices.each_index do |i| + # insert empty values + if timeslices[i].nil? + current_feed = empty_feed.clone + current_feed.created_at = (start_time + (i * seconds)) + timeslices[i] = current_feed + # else average the inner array + else + sum_feed = empty_feed.clone + sum_feed.created_at = timeslices[i].first.created_at + # for each feed + timeslices[i].each do |f| + # for each attribute, add to sum_feed so that we have the total + sum_feed.attribute_names.each do |attr| + + # only add non-null integer fields + if attr.index('field') and !f[attr].nil? and is_a_number?(f[attr]) + # set comma_flag once if we find a number with a comma + comma_flag = true if !comma_flag and f[attr].to_s.index(',') + + # set initial data + if sum_feed[attr].nil? + sum_feed[attr] = parsefloat(f[attr]) + # add data + elsif f[attr] + sum_feed[attr] = parsefloat(sum_feed[attr]) + parsefloat(f[attr]) + end + end + + end + end + + # set to the averaged feed + timeslices[i] = object_average(sum_feed, timeslices[i].length, comma_flag, params[:round]) + end + end + + return timeslices + end + + # slice feed into medians + def feeds_into_medians(feeds) + # convert timescale (minutes) into seconds + seconds = params[:median].to_i * 60 + # get floored time ranges + start_time = get_floored_time(feeds.first.created_at, seconds) + end_time = get_floored_time(feeds.last.created_at, seconds) + + # create empty array with appropriate size + timeslices = Array.new(((end_time - start_time) / seconds).floor) + + # create a blank clone of the first feed so that we only get the necessary attributes + empty_feed = create_empty_clone(feeds.first) + + # add feeds to array + feeds.each do |f| + i = ((f.created_at - start_time) / seconds).floor + f.created_at = start_time + i * seconds + # create multidimensional array + timeslices[i] = [] if timeslices[i].nil? + timeslices[i].push(f) + end + + # keep track of whether numbers use commas as decimals + comma_flag = false + + # fill in array + timeslices.each_index do |i| + # insert empty values + if timeslices[i].nil? + current_feed = empty_feed.clone + current_feed.created_at = (start_time + (i * seconds)) + timeslices[i] = current_feed + # else get median values for the inner array + else + + # create blank hash called 'fields' to hold data + fields = {} + + # for each feed + timeslices[i].each do |f| + + # for each attribute + f.attribute_names.each do |attr| + if attr.index('field') + + # create blank array for each field + fields["#{attr}"] = [] if fields["#{attr}"].nil? + + # push numeric field data onto its array + if is_a_number?(f[attr]) + # set comma_flag once if we find a number with a comma + comma_flag = true if !comma_flag and f[attr].to_s.index(',') + + fields["#{attr}"].push(parsefloat(f[attr])) + end + + end + end + + end + + # sort fields arrays + fields.each_key do |key| + fields[key] = fields[key].compact.sort + end + + # get the median + median_feed = empty_feed.clone + median_feed.created_at = timeslices[i].first.created_at + median_feed.attribute_names.each do |attr| + median_feed[attr] = object_median(fields[attr], comma_flag, params[:round]) if attr.index('field') + end + + timeslices[i] = median_feed + + end + end + + return timeslices + end + + # slice feed into sums + def feeds_into_sums(feeds) + # convert timescale (minutes) into seconds + seconds = params[:sum].to_i * 60 + # get floored time ranges + start_time = get_floored_time(feeds.first.created_at, seconds) + end_time = get_floored_time(feeds.last.created_at, seconds) + + # create empty array with appropriate size + timeslices = Array.new(((end_time - start_time) / seconds).floor) + + # create a blank clone of the first feed so that we only get the necessary attributes + empty_feed = create_empty_clone(feeds.first) + + # add feeds to array + feeds.each do |f| + i = ((f.created_at - start_time) / seconds).floor + f.created_at = start_time + i * seconds + # create multidimensional array + timeslices[i] = [] if timeslices[i].nil? + timeslices[i].push(f) + end + + # keep track of whether numbers use commas as decimals + comma_flag = false + + # fill in array + timeslices.each_index do |i| + # insert empty values + if timeslices[i].nil? + current_feed = empty_feed.clone + current_feed.created_at = (start_time + (i * seconds)) + timeslices[i] = current_feed + # else sum the inner array + else + sum_feed = empty_feed.clone + sum_feed.created_at = timeslices[i].first.created_at + # for each feed + timeslices[i].each do |f| + # for each attribute, add to sum_feed so that we have the total + sum_feed.attribute_names.each do |attr| + # only add non-null integer fields + if attr.index('field') and !f[attr].nil? and is_a_number?(f[attr]) + + # set comma_flag once if we find a number with a comma + comma_flag = true if !comma_flag and f[attr].to_s.index(',') + + # set initial data + if sum_feed[attr].nil? + sum_feed[attr] = parsefloat(f[attr]) + # add data + elsif f[attr] + sum_feed[attr] = parsefloat(sum_feed[attr]) + parsefloat(f[attr]) + end + + end + end + end + + # set to the summed feed + timeslices[i] = object_sum(sum_feed, comma_flag, params[:round]) + end + end + + return timeslices + end + + def is_a_number?(s) + s.to_s.gsub(/,/, '.').match(/\A[+-]?\d+?(\.\d+)?\Z/) == nil ? false : true + end + + def parsefloat(number) + return number.to_s.gsub(/,/, '.').to_f + end + + # gets the median for an object + def object_median(object, comma_flag=false, round=nil) + return nil if object.nil? + length = object.length + return nil if length == 0 + output = '' + + # do the calculation + if length % 2 == 0 + output = (object[(length - 1) / 2] + object[length / 2]) / 2 + else + output = object[(length - 1) / 2] + end + + output = sprintf "%.#{round}f", output if round and is_a_number?(output) + + # replace decimals with commas if appropriate + output = output.to_s.gsub(/\./, ',') if comma_flag + + return output.to_s + end + + # averages a summed object over length + def object_average(object, length, comma_flag=false, round=nil) + object.attribute_names.each do |attr| + # only average non-null integer fields + if !object[attr].nil? and is_a_number?(object[attr]) + if round + object[attr] = sprintf "%.#{round}f", (parsefloat(object[attr]) / length) + else + object[attr] = (parsefloat(object[attr]) / length).to_s + end + # replace decimals with commas if appropriate + object[attr] = object[attr].gsub(/\./, ',') if comma_flag + end + end + + return object + end + + # formats a summed object correctly + def object_sum(object, comma_flag=false, round=nil) + object.attribute_names.each do |attr| + # only average non-null integer fields + if !object[attr].nil? and is_a_number?(object[attr]) + if round + object[attr] = sprintf "%.#{round}f", parsefloat(object[attr]) + else + object[attr] = parsefloat(object[attr]).to_s + end + # replace decimals with commas if appropriate + object[attr] = object[attr].gsub(/\./, ',') if comma_flag + end + end + + return object + end + + # creates an empty clone of an object + def create_empty_clone(object) + empty_clone = object.clone + empty_clone.attribute_names.each { |attr| empty_clone[attr] = nil } + return empty_clone + end + + # gets time floored to proper interval + def get_floored_time(input_time, seconds) + return Time.zone.at((input_time.to_f / seconds).floor * seconds) + end + +end diff --git a/app/controllers/mailer_controller.rb b/app/controllers/mailer_controller.rb new file mode 100644 index 0000000..3104144 --- /dev/null +++ b/app/controllers/mailer_controller.rb @@ -0,0 +1,23 @@ +class MailerController < ApplicationController + + def resetpassword + # protect against bots + render :text => '' and return if params[:userlogin].length > 0 + + @user = User.find_by_login_or_email(params[:user][:login]) + if @user.nil? + sleep 2 + session[:mail_message] = t(:account_not_found) + else + begin + @user.reset_perishable_token! + #Mailer.password_reset(@user, "https://www.thingspeak.com/users/reset_password/#{@user.id}?token=#{@user.perishable_token}").deliver + session[:mail_message] = t(:password_reset_mailed) + rescue + session[:mail_message] = t(:password_reset_error) + end + end + redirect_to :controller => 'user_session', :action => 'new' + end + +end diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb new file mode 100644 index 0000000..23d7a02 --- /dev/null +++ b/app/controllers/pages_controller.rb @@ -0,0 +1,7 @@ +class PagesController < ApplicationController + + def home + @menu = 'home' + end + +end diff --git a/app/controllers/status_controller.rb b/app/controllers/status_controller.rb new file mode 100644 index 0000000..979869e --- /dev/null +++ b/app/controllers/status_controller.rb @@ -0,0 +1,113 @@ +class StatusController < ApplicationController + require 'csv' + + def index + @channel = Channel.find(params[:channel_id]) + @api_key = ApiKey.find_by_api_key(get_userkey) + @success = channel_permission?(@channel, @api_key) + + # check for access + if @success + # create options hash + channel_options = { :only => channel_select_terse(@channel) } + + # display only 1 day by default + params[:days] = 1 if !params[:days] + + # get feed based on conditions + @feeds = Feed.find( + :all, + :conditions => { :channel_id => @channel.id, :created_at => get_date_range(params) }, + :select => [:created_at, :status], + :order => 'created_at' + ) + + # set output correctly + if params[:format] == 'xml' + @channel_xml = @channel.to_xml(channel_options).sub('', '').strip + @feed_xml = @feeds.to_xml(:skip_instruct => true).gsub(/\n/, "\n ").chop.chop + elsif params[:format] == 'csv' + @csv_headers = [:created_at, :status] + else + @channel_json = @channel.to_json(channel_options).chop + @feed_json = @feeds.to_json + end + # else set error code + else + if params[:format] == 'xml' + @channel_xml = bad_channel_xml + else + @channel_json = '-1'.to_json + end + end + + # set callback for jsonp + @callback = params[:callback] if params[:callback] + + # output data in proper format + respond_to do |format| + format.html { render :text => @feed_json } + format.json { render :action => 'feed/index' } + format.xml { render :action => 'feed/index' } + format.csv { render :action => 'feed/index' } + end + end + + def show + @channel = Channel.find(params[:channel_id]) + @api_key = ApiKey.find_by_api_key(params[:key]) + output = '-1' + + # get most recent entry if necessary + params[:id] = @channel.last_entry_id if params[:id] == 'last' + + @feed = Feed.find( + :first, + :conditions => { :channel_id => @channel.id, :entry_id => params[:id] }, + :select => [:created_at, :status] + ) + @success = channel_permission?(@channel, @api_key) + + # check for access + if @success + # set output correctly + if params[:format] == 'xml' + output = @feed.to_xml + elsif params[:format] == 'csv' + @csv_headers = [:created_at, :entry_id, :status] + elsif (params[:format] == 'txt' or params[:format] == 'text') + output = add_prepend_append(@feed.status) + else + output = @feed.to_json + end + # else set error code + else + if params[:format] == 'xml' + output = bad_feed_xml + else + output = '-1'.to_json + end + end + + # output data in proper format + respond_to do |format| + format.html { render :json => output } + format.json { render :json => output, :callback => params[:callback] } + format.xml { render :xml => output } + format.csv { render :action => 'feed/show' } + format.text { render :text => output } + end + end + + private + # only output these fields for channel + def channel_select_terse(channel) + only = [:name] + only += [:latitude] unless channel.latitude.nil? + only += [:longitude] unless channel.longitude.nil? + only += [:elevation] unless channel.elevation.nil? or channel.elevation.empty? + + return only + end + +end diff --git a/app/controllers/subdomains_controller.rb b/app/controllers/subdomains_controller.rb new file mode 100644 index 0000000..ec3a272 --- /dev/null +++ b/app/controllers/subdomains_controller.rb @@ -0,0 +1,15 @@ +class SubdomainsController < ApplicationController + + # show a blank page if subdomain + def index + render :text => '' + end + + # output the file crossdomain.xml.erb + def crossdomain + respond_to do |format| + format.xml + end + end + +end diff --git a/app/controllers/user_sessions_controller.rb b/app/controllers/user_sessions_controller.rb new file mode 100644 index 0000000..52d4c6a --- /dev/null +++ b/app/controllers/user_sessions_controller.rb @@ -0,0 +1,44 @@ +class UserSessionsController < ApplicationController + before_filter :require_no_user, :only => [:new, :create] + before_filter :require_user, :only => :destroy + + def new + @title = t(:signin) + @user_session = UserSession.new + @mail_message = session[:mail_message] if !session[:mail_message].nil? + end + + def show + redirect_to root_path + end + + def create + if params[:userlogin].length > 0 + render :text => '' + else + @user_session = UserSession.new(params[:user_session]) + + # remember user_id if checkbox is checked + if params[:user_session][:remember_id] == '1' + cookies['user_id'] = { :value => params[:user_session][:login], :expires => 1.month.from_now } + else + cookies.delete 'user_id' + end + + if @user_session.save + redirect_to root_path and return + else + # prevent timing and brute force password attacks + sleep 1 + @failed = true + render :action => :new + end + end + end + + def destroy + current_user_session.destroy + reset_session + redirect_to root_path + end +end \ No newline at end of file diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb new file mode 100644 index 0000000..c9194f9 --- /dev/null +++ b/app/controllers/users_controller.rb @@ -0,0 +1,92 @@ +class UsersController < ApplicationController + before_filter :require_no_user, :only => [:new, :create, :forgot_password] + before_filter :require_user, :only => [:show, :edit, :update, :change_password] + + def new + @title = t(:signup) + @user = User.new + end + + def create + # protect against bots + render :text => '' and return if params[:userlogin].length > 0 + + # check for invite code + render :text => 'Sorry, you currently need an invite code to sign up.' and return if params[:invite] != '4224' + + @user = User.new(params[:user]) + + # save user + if @user.valid? + if @user.save + redirect_back_or_default account_path and return + end + else + render :action => :new + end + + end + + def show + @menu = 'account' + @user = @current_user + end + + def edit + @menu = 'account' + @user = @current_user + end + + # displays forgot password page + def forgot_password + @user = User.new + end + + # this action is called from an email link when a password reset is requested + def reset_password + # if user has been logged in (due to previous form submission) + if !current_user.nil? + @user = current_user + @user.errors.add_to_base(t(:password_problem)) + @valid_link = true + else + @user = User.find_by_id(params[:id]) + # make sure tokens match and password reset is within last 10 minutes + if @user.perishable_token == params[:token] && @user.updated_at > 600.seconds.ago + @valid_link = true + # log the user in + @user_session = UserSession.new(@user) + @user_session.save + end + end + end + + # do the actual password change + def change_password + # protect against bots + render :text => '' and return if params[:userlogin].length > 0 + + @user = current_user + # if no password entered, redirect + redirect_to reset_password_path and return if params[:user][:password].empty? + # check current password and update + if @user.update_attributes(params[:user]) + redirect_to account_path + else + redirect_to reset_password_path + end + end + + def update + @menu = 'account' + @user = @current_user # makes our views "cleaner" and more consistent + # check current password and update + if @user.valid_password?(params[:password_current]) && @user.update_attributes(params[:user]) + redirect_to account_path + else + @user.errors.add_to_base(t(:password_incorrect)) + render :action => :edit + end + end + +end \ No newline at end of file diff --git a/app/helpers/api_keys_helper.rb b/app/helpers/api_keys_helper.rb new file mode 100644 index 0000000..a53fd15 --- /dev/null +++ b/app/helpers/api_keys_helper.rb @@ -0,0 +1,2 @@ +module ApiKeysHelper +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb new file mode 100644 index 0000000..de6be79 --- /dev/null +++ b/app/helpers/application_helper.rb @@ -0,0 +1,2 @@ +module ApplicationHelper +end diff --git a/app/helpers/charts_helper.rb b/app/helpers/charts_helper.rb new file mode 100644 index 0000000..77f7e50 --- /dev/null +++ b/app/helpers/charts_helper.rb @@ -0,0 +1,2 @@ +module ChartsHelper +end diff --git a/app/helpers/feed_helper.rb b/app/helpers/feed_helper.rb new file mode 100644 index 0000000..6709856 --- /dev/null +++ b/app/helpers/feed_helper.rb @@ -0,0 +1,2 @@ +module FeedHelper +end diff --git a/app/helpers/mailer_helper.rb b/app/helpers/mailer_helper.rb new file mode 100644 index 0000000..6156b95 --- /dev/null +++ b/app/helpers/mailer_helper.rb @@ -0,0 +1,2 @@ +module MailerHelper +end diff --git a/app/helpers/pages_helper.rb b/app/helpers/pages_helper.rb new file mode 100644 index 0000000..2c057fd --- /dev/null +++ b/app/helpers/pages_helper.rb @@ -0,0 +1,2 @@ +module PagesHelper +end diff --git a/app/helpers/status_helper.rb b/app/helpers/status_helper.rb new file mode 100644 index 0000000..fc2a6e1 --- /dev/null +++ b/app/helpers/status_helper.rb @@ -0,0 +1,2 @@ +module StatusHelper +end diff --git a/app/helpers/subdomains_helper.rb b/app/helpers/subdomains_helper.rb new file mode 100644 index 0000000..7dbfea9 --- /dev/null +++ b/app/helpers/subdomains_helper.rb @@ -0,0 +1,2 @@ +module SubdomainsHelper +end diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb new file mode 100644 index 0000000..2310a24 --- /dev/null +++ b/app/helpers/users_helper.rb @@ -0,0 +1,2 @@ +module UsersHelper +end diff --git a/app/models/api_key.rb b/app/models/api_key.rb new file mode 100644 index 0000000..9b47730 --- /dev/null +++ b/app/models/api_key.rb @@ -0,0 +1,24 @@ +class ApiKey < ActiveRecord::Base + belongs_to :channel + + validates_uniqueness_of :api_key +end + + + + + +# == Schema Information +# +# Table name: api_keys +# +# id :integer(4) not null, primary key +# api_key :string(16) +# channel_id :integer(4) +# user_id :integer(4) +# write_flag :boolean(1) default(FALSE) +# created_at :datetime +# updated_at :datetime +# note :string(255) +# + diff --git a/app/models/channel.rb b/app/models/channel.rb new file mode 100644 index 0000000..282a8f9 --- /dev/null +++ b/app/models/channel.rb @@ -0,0 +1,44 @@ +class Channel < ActiveRecord::Base + belongs_to :user + has_many :feeds + has_many :api_keys +end + + + + + + +# == Schema Information +# +# Table name: channels +# +# id :integer(4) not null, primary key +# user_id :integer(4) +# name :string(255) +# description :string(255) +# latitude :decimal(15, 10) +# longitude :decimal(15, 10) +# field1 :text +# field2 :text +# field3 :text +# field4 :text +# field5 :text +# field6 :text +# field7 :text +# field8 :text +# scale1 :integer(4) +# scale2 :integer(4) +# scale3 :integer(4) +# scale4 :integer(4) +# scale5 :integer(4) +# scale6 :integer(4) +# scale7 :integer(4) +# scale8 :integer(4) +# created_at :datetime +# updated_at :datetime +# elevation :string(255) +# last_entry_id :integer(4) +# public_flag :boolean(1) default(FALSE) +# + diff --git a/app/models/feed.rb b/app/models/feed.rb new file mode 100644 index 0000000..866ea08 --- /dev/null +++ b/app/models/feed.rb @@ -0,0 +1,30 @@ +class Feed < ActiveRecord::Base + belongs_to :channel + + self.include_root_in_json = false +end + + + + + +# == Schema Information +# +# Table name: feeds +# +# id :integer(4) not null, primary key +# channel_id :integer(4) +# raw_data :text +# field1 :text +# field2 :text +# field3 :text +# field4 :text +# field5 :text +# field6 :text +# field7 :text +# field8 :text +# created_at :datetime +# updated_at :datetime +# entry_id :integer(4) +# + diff --git a/app/models/mailer.rb b/app/models/mailer.rb new file mode 100644 index 0000000..9bd88d9 --- /dev/null +++ b/app/models/mailer.rb @@ -0,0 +1,11 @@ +class Mailer < ActionMailer::Base + #default :from => 'support@thingspeak.com' + + def password_reset(user, webpage) + @user = user + @webpage = webpage + mail(:to => @user.email, + :subject => t(:password_reset_subject)) + end + +end diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 0000000..d923483 --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,31 @@ +class User < ActiveRecord::Base + has_many :channels + + acts_as_authentic + + def self.find_by_login_or_email(login) + User.find_by_login(login) || User.find_by_email(login) + end +end + + +# == Schema Information +# +# Table name: users +# +# id :integer(4) not null, primary key +# login :string(255) not null +# email :string(255) not null +# crypted_password :string(255) not null +# password_salt :string(255) not null +# persistence_token :string(255) not null +# perishable_token :string(255) not null +# current_login_at :datetime +# last_login_at :datetime +# current_login_ip :string(255) +# last_login_ip :string(255) +# created_at :datetime +# updated_at :datetime +# time_zone :string(255) +# + diff --git a/app/models/user_session.rb b/app/models/user_session.rb new file mode 100644 index 0000000..457f515 --- /dev/null +++ b/app/models/user_session.rb @@ -0,0 +1,7 @@ +class UserSession < Authlogic::Session::Base + find_by_login_method :find_by_login_or_email + + def to_key + new_record? ? nil : [ self.send(self.class.primary_key) ] + end +end \ No newline at end of file diff --git a/app/views/api_keys/index.html.erb b/app/views/api_keys/index.html.erb new file mode 100644 index 0000000..23ac5c4 --- /dev/null +++ b/app/views/api_keys/index.html.erb @@ -0,0 +1,35 @@ +
<%= t(:api_key_key) %>: | +<%= read_key.api_key %> | +
<%= t(:note) %>: | ++ <%= form_for read_key, :as => :api_key, :url => { :controller => 'api_keys', :action => 'update' }, :html => {:method => 'put'} do |f| %> + <%= f.text_area :note, :cols => 30, :rows => 4 %> + | +
<%= f.hidden_field :api_key, :value => read_key.api_key %> | +
+ <%= f.submit t(:note_save) %>
+ <% end %>
+ <%= button_to t(:api_key_delete), :controller => 'api_keys', :action => 'destroy', :api_key => read_key.api_key %> |
+
<%= t(:channel_name) %> | +<%= d.text_field :name %> | +
<%= t(:channel_description) %> | +<%= d.text_field :description %> | +
<%= t(:api_key) %> | +<%= @key %> | +
<%= t(:latitude) %> | +<%= d.text_field :latitude %> | +
<%= t(:longitude) %> | +<%= d.text_field :longitude %> | +
<%= t(:elevation) %> | +<%= d.text_field :elevation %> | +
<%= t(:public) %> | +<%= d.check_box :public_flag %> | +
<%= t(:field) %> 1 | +<%= d.text_field :field1, :class => 'field' %> | +
<%= t(:field) %> 2 | +<%= d.text_field :field2, :class => 'field' %> | +
<%= t(:field) %> 3 | +<%= d.text_field :field3, :class => 'field' %> | +
<%= t(:field) %> 4 | +<%= d.text_field :field4, :class => 'field' %> | +
<%= t(:field) %> 5 | +<%= d.text_field :field5, :class => 'field' %> | +
<%= t(:field) %> 6 | +<%= d.text_field :field6, :class => 'field' %> | +
<%= t(:field) %> 7 | +<%= d.text_field :field7, :class => 'field' %> | +
<%= t(:field) %> 8 | +<%= d.text_field :field8, :class => 'field' %> | +
+ | <%= d.submit t(:channel_update) %> + |
<%= t(:channel_name) %>: | +<%= @channel.name %> | +
<%= t(:api_key) %>: | +<%= @key %> | +
<%= t(:channel_description) %>: | +<%= @channel.description %> | +
<%= t(:created) %>: | +<%= l @channel.created_at, :format => :pretty %> | +
<%= t(:latitude) %>: | +<%= @channel.latitude %> | +
<%= t(:longitude) %>: | +<%= @channel.longitude %> | +
<%= t(:elevation) %>: | +<%= @channel.elevation %> | +
<%= t(:field) %> 1: | +<%= @channel.field1 %> | +
<%= t(:field) %> 2: | +<%= @channel.field2 %> | +
<%= t(:field) %> 3: | +<%= @channel.field3 %> | +
<%= t(:field) %> 4: | +<%= @channel.field4 %> | +
<%= t(:field) %> 5: | +<%= @channel.field5 %> | +
<%= t(:field) %> 6: | +<%= @channel.field6 %> | +
<%= t(:field) %> 7: | +<%= @channel.field7 %> | +
<%= t(:field) %> 8: | +<%= @channel.field8 %> | +
<%= t(:title) %>: | ++ |
<%= t(:chart_xaxis) %>: | ++ |
<%= t(:chart_yaxis) %>: | ++ |
<%= t(:chart_color) %>: | ++ |
<%= t(:chart_background_color) %>: | ++ |
<%= t(:chart_type) %>: | ++ + | +
+ | + |
<%= t(:days) %>: | ++ |
<%= t(:timescale) %>: | ++ |
<%= t(:average) %>: | ++ |
<%= t(:median) %>: | ++ |
<%= t(:sum) %>: | ++ |
<%= t(:chart_round) %>: | ++ |
<%= t(:width) %>: | ++ |
<%= t(:height) %>: | ++ |
+ <%= t(:password_reset_message1) %>
+
+ <%= t(:password_reset_message2) %>
+
+ <%= t(:password_reset_message3) %>
+
+ <%= @webpage %>
+
+ <%= t(:secure_signin) %> + | +|
<%= t(:userid) %> | +<%= f.text_field :login, :size => 15, :value => cookies['user_id'] %> | +
<%= t(:password) %> | +<%= f.password_field :password, :size => 15 %> | +
<%= f.check_box :remember_id, :checked => true %> | +<%= t(:remember_me) %> | +
+ | <%= link_to t(:forgot), forgot_password_path, :id => 'forgot_password' %> | +
+ | <%= f.submit t(:signin) %> | +
+ <%= f.label :login, t(:userid) %>
+ |
+ + <%= f.text_field :login %> + | +
+ <%= f.label t(:email) %>
+ |
+ + <%= f.text_field :email %> + | +
<%= t(:time_zone) %> | +<%= time_zone_select 'user', 'time_zone', nil, :default => 'Eastern Time (US & Canada)' %> | +
+ <%= f.label :password, raw(t(:password_change_raw)) %>
+ |
+ + <%= f.password_field :password %> + | +
+ <%= f.label :password_confirmation, raw(t(:password_confirmation_raw)) %> + | ++ <%= f.password_field :password_confirmation %> + | +
+ <%= raw(t(:password_current_raw)) %> + | +
+
+ + <%= t(:account_security) %> + |
+
+ | <%= f.submit t(:account_edit_submit) %> | +
+ <%= f.label :login, t(:userid) %>
+ |
+ + <%= f.text_field :login %> + | +
+ <%= f.label t(:email) %>
+ |
+ + <%= f.text_field :email %> + | +
<%= t(:time_zone) %> | +<%= time_zone_select 'user', 'time_zone', nil, :default => 'Eastern Time (US & Canada)' %> | +
+ <%= f.label t(:password) %>
+ |
+ + <%= f.password_field :password %> + | +
+ <%= f.label :password_confirmation, raw(t(:password_confirmation_raw)) %> + | ++ <%= f.password_field :password_confirmation %> + | +
+ Invite Code + | ++ + | +
+ | + <%= t(:tos_agree) %> <%= link_to t(:tos), { :controller => 'pages', :action => 'terms' }, :target => '_blank' %>. + | +
+ | <%= f.submit t(:create_account) %> | +
+ <%= f.label :password %>
+ |
+
+ <%= f.password_field :password %>
+ + <%= t(:password_new_choose) %> + |
+
+ <%= f.label :password_confirmation, raw(t(:password_confirmation_raw)) %> + | +
+ <%= f.password_field :password_confirmation %>
+ + <%= t(:password_new_confirmation) %> + |
+
+ | <%= f.submit t(:submit) %> | +
<%= t(:userid) %> | +<%= @user.login %> | +
<%= t(:email) %> | +<%= @user.email %> | +
<%= t(:time_zone) %> | +<%= @user.time_zone %> | +
You may have mistyped the address or the page may have moved.
+Maybe you tried to change something you didn't have access to.
+We've been notified about this issue and we'll take a look at it shortly.
+p&&ra.shift();if(h.endOnTick)P=r;else P =1E3?Gd(k,0):k},Nc=M&&h.labels.staggerLines,Xb=h.reversed,Yb=Va&&h.tickmarkPlacement=="between"?0.5:0;x.prototype={addLabel:function(){var k=this.pos,p=h.labels,r=!(k==
+K&&!y(h.showFirstLabel,1)||k==P&&!y(h.showLastLabel,0)),o=Va&&M&&Va.length&&!p.step&&!p.staggerLines&&!p.rotation&&Aa/Va.length||!M&&Aa/2,T=this.label;k=$d.call({isFirst:k==ra[0],isLast:k==ra[ra.length-1],dateTimeLabelFormat:Kc,value:Va&&Va[k]?Va[k]:k});o=o&&{width:o-2*(p.padding||10)+$a};o=oa(o,p.style);if(T===Ra)this.label=I(k)&&r&&p.enabled?ca.text(k,0,0).attr({align:p.align,rotation:p.rotation}).css(o).add(rb):null;else T&&T.attr({text:k}).css(o)},getLabelSize:function(){var k=this.label;return k?
+(this.labelBBox=k.getBBox())[M?"height":"width"]:0},render:function(k,p){var r=!this.minor,o=this.label,T=this.pos,Z=h.labels,G=this.gridLine,B=r?h.gridLineWidth:h.minorGridLineWidth,ha=r?h.gridLineColor:h.minorGridLineColor,la=r?h.gridLineDashStyle:h.minorGridLineDashStyle,C=this.mark,na=r?h.tickLength:h.minorTickLength,S=r?h.tickWidth:h.minorTickWidth||0,aa=r?h.tickColor:h.minorTickColor,pc=r?h.tickPosition:h.minorTickPosition;r=Z.step;var hb=p&&Oc||Pa,Ob;Ob=M?va(T+Yb,null,null,p)+wa:X+Q+(Oa?(p&&
+hd||Xa)-zb-X:0);hb=M?hb-pb+Q-(Oa?ta:0):hb-va(T+Yb,null,null,p)-wa;if(B){T=Ka(T+Yb,B,p);if(G===Ra){G={stroke:ha,"stroke-width":B};if(la)G.dashstyle=la;this.gridLine=G=B?ca.path(T).attr(G).add(Gb):null}G&&T&&G.animate({d:T})}if(S){if(pc=="inside")na=-na;if(Oa)na=-na;B=ca.crispLine([Za,Ob,hb,Ca,Ob+(M?0:-na),hb+(M?na:0)],S);if(C)C.animate({d:B});else this.mark=ca.path(B).attr({stroke:aa,"stroke-width":S}).add(rb)}if(o){Ob=Ob+Z.x-(Yb&&M?Yb*D*(Xb?-1:1):0);hb=hb+Z.y-(Yb&&!M?Yb*D*(Xb?1:-1):0);I(Z.y)||(hb+=
+parseInt(o.styles.lineHeight)*0.9-o.getBBox().height/2);if(Nc)hb+=k%Nc*16;if(r)o[k%r?"hide":"show"]();o[this.isNew?"attr":"animate"]({x:Ob,y:hb})}this.isNew=false},destroy:function(){for(var k in this)this[k]&&this[k].destroy&&this[k].destroy()}};w.prototype={render:function(){var k=this,p=k.options,r=p.label,o=k.label,T=p.width,Z=p.to,G,B=p.from,ha=p.dashStyle,la=k.svgElem,C=[],na,S,aa=p.color;S=p.zIndex;var pc=p.events;if(T){C=Ka(p.value,T);p={stroke:aa,"stroke-width":T};if(ha)p.dashstyle=ha}else if(I(B)&&
+I(Z)){B=Da(B,K);Z=nb(Z,P);G=Ka(Z);if((C=Ka(B))&&G)C.push(G[4],G[5],G[1],G[2]);else C=null;p={fill:aa}}else return;if(I(S))p.zIndex=S;if(la)if(C)la.animate({d:C},null,la.onGetPath);else{la.hide();la.onGetPath=function(){la.show()}}else if(C&&C.length){k.svgElem=la=ca.path(C).attr(p).add();if(pc){ha=function(hb){la.on(hb,function(Ob){pc[hb].apply(k,[Ob])})};for(na in pc)ha(na)}}if(r&&I(r.text)&&C&&C.length&&Aa>0&&ta>0){r=xa({align:M&&G&&"center",x:M?!G&&4:10,verticalAlign:!M&&G&&"middle",y:M?G?16:10:
+G?6:-4,rotation:M&&!G&&90},r);if(!o)k.label=o=ca.text(r.text,0,0).attr({align:r.textAlign||r.align,rotation:r.rotation,zIndex:S}).css(r.style).add();G=[C[1],C[4],C[6]||C[1]];C=[C[2],C[5],C[7]||C[2]];na=nb.apply(Ua,G);S=nb.apply(Ua,C);o.align(r,false,{x:na,y:S,width:Da.apply(Ua,G)-na,height:Da.apply(Ua,C)-S});o.show()}else o&&o.hide();return k},destroy:function(){for(var k in this){this[k]&&this[k].destroy&&this[k].destroy();delete this[k]}mc(Nb,this)}};va=function(k,p,r,o){var T=1,Z=0,G=o?ja:D;o=
+o?gb:K;G||(G=D);if(r){T*=-1;Z=A}if(Xb){T*=-1;Z-=T*A}if(p){if(Xb)k=A-k;k=k/G+o}else k=T*(k-o)*G+Z;return k};Ka=function(k,p,r){var o,T,Z;k=va(k,null,null,r);var G=r&&Oc||Pa,B=r&&hd||Xa,ha;r=T=U(k+wa);o=Z=U(G-k-wa);if(isNaN(k))ha=true;else if(M){o=da;Z=G-pb;if(r
"]:[];t(H,function(va){wa.push(va.point.tooltipFormatter(ja))});return wa.join("")}function x(H,A){E=ma?H:(2*E+H)/3;fa=ma?A:(fa+A)/2;s.translate(E,fa);id=cb(H-E)>1||cb(A-fa)>1?function(){x(H,A)}:null}function w(){if(!ma){var H=q.hoverPoints;s.hide();t(ga,function(A){A&&A.hide()});H&&t(H,function(A){A.setState()});q.hoverPoints=null;ma=true}}var O,ia=m.borderWidth,L=m.crosshairs,ga=[],Ea=m.style,ua=m.shared,bb=pa(Ea.padding),Ja=ia+bb,ma=true,Oa,M,E=0,fa=0;Ea.padding=
+0;var s=ca.g("tooltip").attr({zIndex:8}).add(),N=ca.rect(Ja,Ja,0,0,m.borderRadius,ia).attr({fill:m.backgroundColor,"stroke-width":ia}).add(s).shadow(m.shadow),Q=ca.text("",bb+Ja,pa(Ea.fontSize)+bb+Ja).attr({zIndex:1}).css(Ea).add(s);s.hide();return{shared:ua,refresh:function(H){var A,D,ja,wa=0,va={},Ka=[];ja=H.tooltipPos;A=m.formatter||h;va=q.hoverPoints;var rb=function(Fa){return{series:Fa.series,point:Fa,x:Fa.category,y:Fa.y,percentage:Fa.percentage,total:Fa.total||Fa.stackTotal}};if(ua){va&&t(va,
+function(Fa){Fa.setState()});q.hoverPoints=H;t(H,function(Fa){Fa.setState(xb);wa+=Fa.plotY;Ka.push(rb(Fa))});D=H[0].plotX;wa=U(wa)/H.length;va={x:H[0].category};va.points=Ka;H=H[0]}else va=rb(H);va=A.call(va);O=H.series;D=ua?D:H.plotX;wa=ua?wa:H.plotY;A=U(ja?ja[0]:Ga?Aa-wa:D);D=U(ja?ja[1]:Ga?ta-D:wa);ja=ua||!H.series.isCartesian||hc(A,D);if(va===false||!ja)w();else{if(ma){s.show();ma=false}Q.attr({text:va});ja=Q.getBBox();Oa=ja.width+2*bb;M=ja.height+2*bb;N.attr({width:Oa,height:M,stroke:m.borderColor||
+H.color||O.color||"#606060"});A=A-Oa+X-25;D=D-M+da+10;if(A<7){A=7;D-=30}if(D<5)D=5;else if(D+M>Pa)D=Pa-M-5;x(U(A-Ja),U(D-Ja))}if(L){L=nc(L);D=L.length;for(var Gb;D--;)if(L[D]&&(Gb=H.series[D?"yAxis":"xAxis"])){A=Gb.getPlotLinePath(H[D?"y":"x"],1);if(ga[D])ga[D].attr({d:A,visibility:Ab});else{ja={"stroke-width":L[D].width||1,stroke:L[D].color||"#C0C0C0",zIndex:2};if(L[D].dashStyle)ja.dashstyle=L[D].dashStyle;ga[D]=ca.path(A).attr(ja).add()}}}},hide:w}}function f(m,h){function x(E){var fa;E=E||sb.event;
+if(!E.target)E.target=E.srcElement;fa=E.touches?E.touches.item(0):E;if(E.type!="mousemove"||sb.opera){for(var s=sa,N={left:s.offsetLeft,top:s.offsetTop};s=s.offsetParent;){N.left+=s.offsetLeft;N.top+=s.offsetTop;if(s!=za.body&&s!=za.documentElement){N.left-=s.scrollLeft;N.top-=s.scrollTop}}qc=N}if(Ac){E.chartX=E.x;E.chartY=E.y}else if(fa.layerX===Ra){E.chartX=fa.pageX-qc.left;E.chartY=fa.pageY-qc.top}else{E.chartX=E.layerX;E.chartY=E.layerY}return E}function w(E){var fa={xAxis:[],yAxis:[]};t(ab,function(s){var N=
+s.translate,Q=s.isXAxis;fa[Q?"xAxis":"yAxis"].push({axis:s,value:N((Ga?!Q:Q)?E.chartX-X:ta-E.chartY+da,true)})});return fa}function O(){var E=m.hoverSeries,fa=m.hoverPoint;fa&&fa.onMouseOut();E&&E.onMouseOut();rc&&rc.hide();jd=null}function ia(){if(ua){var E={xAxis:[],yAxis:[]},fa=ua.getBBox(),s=fa.x-X,N=fa.y-da;if(Ea){t(ab,function(Q){var H=Q.translate,A=Q.isXAxis,D=Ga?!A:A,ja=H(D?s:ta-N-fa.height,true);H=H(D?s+fa.width:ta-N,true);E[A?"xAxis":"yAxis"].push({axis:Q,min:nb(ja,H),max:Da(ja,H)})});La(m,
+"selection",E,kd)}ua=ua.destroy()}m.mouseIsDown=ld=Ea=false;Bb(za,Hb?"touchend":"mouseup",ia)}var L,ga,Ea,ua,bb=v.zoomType,Ja=/x/.test(bb),ma=/y/.test(bb),Oa=Ja&&!Ga||ma&&Ga,M=ma&&!Ga||Ja&&Ga;Pc=function(){if(Qc){Qc.translate(X,da);Ga&&Qc.attr({width:m.plotWidth,height:m.plotHeight}).invert()}else m.trackerGroup=Qc=ca.g("tracker").attr({zIndex:9}).add()};Pc();if(h.enabled)m.tooltip=rc=e(h);(function(){var E=true;sa.onmousedown=function(s){s=x(s);m.mouseIsDown=ld=true;L=s.chartX;ga=s.chartY;Qa(za,
+Hb?"touchend":"mouseup",ia)};var fa=function(s){if(!(s&&s.touches&&s.touches.length>1)){s=x(s);if(!Hb)s.returnValue=false;var N=s.chartX,Q=s.chartY,H=!hc(N-X,Q-da);if(Hb&&s.type=="touchstart")if(ya(s.target,"isTracker"))m.runTrackerClick||s.preventDefault();else!ae&&!H&&s.preventDefault();if(H){E||O();if(N
]?>/g),d=b.childNodes,e=/style="([^"]+)"/,f=/href="([^"]+)"/,g=ya(b,"x"),i=a.styles,l=Rd&&i&&i.HcDirection=="rtl"&&!this.forExport,j,n=i&&pa(i.width),z=i&&i.lineHeight,F,W=d.length;W--;)b.removeChild(d[W]);n&&!a.added&&
+this.box.appendChild(b);t(c,function(ba,ka){var v,J=0,ea;ba=ba.replace(//g,"|||");v=ba.split("|||");t(v,function(Y){if(Y!==""||v.length==1){var V={},R=za.createElementNS("http://www.w3.org/2000/svg","tspan");e.test(Y)&&ya(R,"style",Y.match(e)[1].replace(/(;| |^)color([ :])/,"$1fill$2"));if(f.test(Y)){ya(R,"onclick",'location.href="'+Y.match(f)[1]+'"');Ia(R,{cursor:"pointer"})}Y=Y.replace(/<(.|\n)*?>/g,"")||" ";if(l){j=[];for(W=Y.length;W--;)j.push(Y.charAt(W));
+Y=j.join("")}R.appendChild(za.createTextNode(Y));if(J)V.dx=3;else V.x=g;if(!J){if(ka){ea=pa(window.getComputedStyle(F,null).getPropertyValue("line-height"));if(isNaN(ea))ea=z||F.offsetHeight||18;ya(R,"dy",ea)}F=R}ya(R,V);b.appendChild(R);J++;if(n){Y=Y.replace(/-/g,"- ").split(" ");for(var Ha,Ya=[];Y.length||Ya.length;){Ha=b.getBBox().width;V=Ha>n;if(!V||Y.length==1){Y=Ya;Ya=[];if(Y.length){R=za.createElementNS("http://www.w3.org/2000/svg","tspan");ya(R,{x:g,dy:z||16});b.appendChild(R);if(Ha>n)n=Ha}}else{R.removeChild(R.firstChild);
+Ya.unshift(Y.pop())}R.appendChild(za.createTextNode(Y.join(" ").replace(/- /g,"-")))}}}})})},crispLine:function(a,b){if(a[1]==a[4])a[1]=a[4]=U(a[1])+b%2/2;if(a[2]==a[5])a[2]=a[5]=U(a[2])+b%2/2;return a},path:function(a){return this.createElement("path").attr({d:a,fill:mb})},circle:function(a,b,c){a=Db(a)?a:{x:a,y:b,r:c};return this.createElement("circle").attr(a)},arc:function(a,b,c,d,e,f){if(Db(a)){b=a.y;c=a.r;d=a.innerR;e=a.start;f=a.end;a=a.x}return this.symbol("arc",a||0,b||0,c||0,{innerR:d||
+0,start:e||0,end:f||0})},rect:function(a,b,c,d,e,f){if(arguments.length>1){var g=(f||0)%2/2;a=U(a||0)+g;b=U(b||0)+g;c=U((c||0)-2*g);d=U((d||0)-2*g)}g=Db(a)?a:{x:a,y:b,width:Da(c,0),height:Da(d,0)};return this.createElement("rect").attr(oa(g,{rx:e||g.r,ry:e||g.r,fill:mb}))},setSize:function(a,b,c){var d=this.alignedObjects,e=d.length;this.width=a;this.height=b;for(this.boxWrapper[y(c,true)?"animate":"attr"]({width:a,height:b});e--;)d[e].align()},g:function(a){return this.createElement("g").attr(I(a)&&
+{"class":Zb+a})},image:function(a,b,c,d,e){var f={preserveAspectRatio:mb};arguments.length>1&&oa(f,{x:b,y:c,width:d,height:e});f=this.createElement("image").attr(f);f.element.setAttributeNS("http://www.w3.org/1999/xlink","href",a);return f},symbol:function(a,b,c,d,e){var f,g=this.symbols[a];g=g&&g(b,c,d,e);var i=/^url\((.*?)\)$/;if(g){f=this.path(g);oa(f,{symbolName:a,x:b,y:c,r:d});e&&oa(f,e)}else if(i.test(a)){a=a.match(i)[1];f=this.image(a).attr({x:b,y:c});fb("img",{onload:function(){var l=de[this.src]||
+[this.width,this.height];f.attr({width:l[0],height:l[1]}).translate(-U(l[0]/2),-U(l[1]/2))},src:a})}else f=this.circle(b,c,d);return f},symbols:{square:function(a,b,c){c=0.707*c;return[Za,a-c,b-c,Ca,a+c,b-c,a+c,b+c,a-c,b+c,"Z"]},triangle:function(a,b,c){return[Za,a,b-1.33*c,Ca,a+c,b+0.67*c,a-c,b+0.67*c,"Z"]},"triangle-down":function(a,b,c){return[Za,a,b+1.33*c,Ca,a-c,b-0.67*c,a+c,b-0.67*c,"Z"]},diamond:function(a,b,c){return[Za,a,b-c,Ca,a+c,b,a,b+c,a-c,b,"Z"]},arc:function(a,b,c,d){var e=d.start,
+f=d.end-1.0E-6,g=d.innerR,i=jb(e),l=yb(e),j=jb(f);f=yb(f);d=d.end-e
"].join("")},getDataLabelText:function(){return this.series.options.dataLabels.formatter.call({x:this.x,
+y:this.y,series:this.series,point:this,percentage:this.percentage,total:this.total||this.stackTotal})},update:function(a,b,c){var d=this,e=d.series,f=d.dataLabel,g=e.chart;b=y(b,true);d.firePointEvent("update",{options:a},function(){d.applyOptions(a);f&&f.attr({text:d.getDataLabelText()});if(Db(a)){e.getAttribs();d.graphic.attr(d.pointAttr[e.state])}e.isDirty=true;b&&g.redraw(c)})},remove:function(a,b){var c=this,d=c.series,e=d.chart,f=d.data;bc(b,e);a=y(a,true);c.firePointEvent("remove",null,function(){mc(f,
+c);c.destroy();d.isDirty=true;a&&e.redraw()})},firePointEvent:function(a,b,c){var d=this,e=this.series.options;if(e.point.events[a]||d.options&&d.options.events&&d.options.events[a])this.importEvents();if(a=="click"&&e.allowPointSelect)c=function(f){d.select(null,f.ctrlKey||f.metaKey||f.shiftKey)};La(this,a,b,c)},importEvents:function(){if(!this.hasImportedEvents){var a=xa(this.series.options.point,this.options).events,b;this.events=a;for(b in a)Qa(this,b,a[b]);this.hasImportedEvents=true}},setState:function(a){var b=
+this.series,c=b.options.states,d=vb[b.type].marker&&b.options.marker,e=d&&!d.enabled,f=(d=d&&d.states[a])&&d.enabled===false,g=b.stateMarkerGraphic,i=b.chart,l=this.pointAttr;a||(a=db);if(!(a==this.state||this.selected&&a!="select"||c[a]&&c[a].enabled===false||a&&(f||e&&!d.enabled))){if(this.graphic)this.graphic.attr(l[a]);else{if(a){if(!g)b.stateMarkerGraphic=g=i.renderer.circle(0,0,l[a].r).attr(l[a]).add(b.group);g.translate(this.plotX,this.plotY)}if(g)g[a?"show":"hide"]()}this.state=a}}};var lb=
+function(){};lb.prototype={isCartesian:true,type:"line",pointClass:zc,pointAttrToOptions:{stroke:"lineColor","stroke-width":"lineWidth",fill:"fillColor",r:"radius"},init:function(a,b){var c,d;d=a.series.length;this.chart=a;b=this.setOptions(b);oa(this,{index:d,options:b,name:b.name||"Series "+(d+1),state:db,pointAttr:{},visible:b.visible!==false,selected:b.selected===true});d=b.events;for(c in d)Qa(this,c,d[c]);if(d&&d.click||b.point&&b.point.events&&b.point.events.click||b.allowPointSelect)a.runTrackerClick=
+true;this.getColor();this.getSymbol();this.setData(b.data,false)},autoIncrement:function(){var a=this.options,b=this.xIncrement;b=y(b,a.pointStart,0);this.pointInterval=y(this.pointInterval,a.pointInterval,1);this.xIncrement=b+this.pointInterval;return b},cleanData:function(){var a=this.chart,b=this.data,c,d,e=a.smallestInterval,f,g;b.sort(function(i,l){return i.x-l.x});for(g=b.length-1;g>=0;g--)b[g-1]&&b[g-1].x==b[g].x&&b.splice(g-1,1);for(g=b.length-1;g>=0;g--)if(b[g-1]){f=b[g].x-b[g-1].x;if(d===
+Ra||f