From 91a8b4ab9cd6c601e98d23e9e316049feea6cbbd Mon Sep 17 00:00:00 2001 From: Steven Miers Date: Mon, 20 Jul 2015 11:01:08 -0500 Subject: [PATCH 001/591] VRFS-3316 : Reviews/Ratings * schemas for reviews and review_summaries * migrations * models * validations * initial specs to verify creation of reviews and review_summaries with a jamtrack and subsequently, various validations. --- db/manifest | 3 +- db/up/reviews.sql | 23 ++++++ ruby/lib/jam_ruby.rb | 2 + ruby/lib/jam_ruby/models/review.rb | 13 ++++ ruby/lib/jam_ruby/models/review_summary.rb | 12 +++ ruby/lib/jam_ruby/models/user.rb | 3 + ruby/spec/jam_ruby/models/review_spec.rb | 86 ++++++++++++++++++++++ 7 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 db/up/reviews.sql create mode 100644 ruby/lib/jam_ruby/models/review.rb create mode 100644 ruby/lib/jam_ruby/models/review_summary.rb create mode 100644 ruby/spec/jam_ruby/models/review_spec.rb diff --git a/db/manifest b/db/manifest index bd6ca8ce6..d0f27852b 100755 --- a/db/manifest +++ b/db/manifest @@ -296,4 +296,5 @@ add_description_to_perf_samples.sql alter_genre_player_unique_constraint.sql musician_search.sql enhance_band_profile.sql -alter_band_profile_rate_defaults.sql \ No newline at end of file +alter_band_profile_rate_defaults.sql +reviews.sql \ No newline at end of file diff --git a/db/up/reviews.sql b/db/up/reviews.sql new file mode 100644 index 000000000..50ae9df5f --- /dev/null +++ b/db/up/reviews.sql @@ -0,0 +1,23 @@ +CREATE TABLE reviews ( + id VARCHAR(64) PRIMARY KEY DEFAULT uuid_generate_v4() NOT NULL, + user_id VARCHAR(64) NOT NULL REFERENCES users(id) ON DELETE CASCADE, + target_id VARCHAR(64) NOT NULL, + target_type VARCHAR(32) NOT NULL, + description VARCHAR(8000), + rating INT NOT NULL, + deleted_by_user_id VARCHAR(64) REFERENCES users(id) ON DELETE SET NULL, + deleted_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NULL, + created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW() NOT NULL, + updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW() NOT NULL +); + +CREATE TABLE review_summaries ( + id VARCHAR(64) PRIMARY KEY DEFAULT uuid_generate_v4() NOT NULL, + target_id VARCHAR(64) NOT NULL, + target_type VARCHAR(32) NOT NULL, + avg_rating FLOAT NOT NULL, + wilson_score FLOAT NOT NULL, + review_count INT NOT NULL, + created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW() NOT NULL, + updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW() NOT NULL +); \ No newline at end of file diff --git a/ruby/lib/jam_ruby.rb b/ruby/lib/jam_ruby.rb index 82484997e..1069aa184 100755 --- a/ruby/lib/jam_ruby.rb +++ b/ruby/lib/jam_ruby.rb @@ -111,6 +111,8 @@ require "jam_ruby/models/machine_fingerprint" require "jam_ruby/models/machine_extra" require "jam_ruby/models/fraud_alert" require "jam_ruby/models/fingerprint_whitelist" +require "jam_ruby/models/review" +require "jam_ruby/models/review_summary" require "jam_ruby/models/rsvp_request" require "jam_ruby/models/rsvp_slot" require "jam_ruby/models/rsvp_request_rsvp_slot" diff --git a/ruby/lib/jam_ruby/models/review.rb b/ruby/lib/jam_ruby/models/review.rb new file mode 100644 index 000000000..454e03943 --- /dev/null +++ b/ruby/lib/jam_ruby/models/review.rb @@ -0,0 +1,13 @@ +module JamRuby + class Review < ActiveRecord::Base + attr_accessible :target, :rating, :description, :user + belongs_to :target, polymorphic: true + belongs_to :user, foreign_key: 'user_id', class_name: "JamRuby::User" + belongs_to :deleted_by_user, foreign_key: 'deleted_by_user_id', class_name: "JamRuby::User" + + validates :rating, presence:true, numericality: {only_integer: true, minimum:1, maximum:5} + validates :target, presence:true + validates :user, presence:true + + end +end \ No newline at end of file diff --git a/ruby/lib/jam_ruby/models/review_summary.rb b/ruby/lib/jam_ruby/models/review_summary.rb new file mode 100644 index 000000000..77e25d0e7 --- /dev/null +++ b/ruby/lib/jam_ruby/models/review_summary.rb @@ -0,0 +1,12 @@ +module JamRuby + class ReviewSummary < ActiveRecord::Base + attr_accessible :target, :target_type, :avg_rating, :wilson_score, :review_count + belongs_to :target, polymorphic: true + belongs_to :user, foreign_key: 'user_id', class_name: "JamRuby::User" + + validates :avg_rating, presence:true, numericality: true + validates :review_count, presence:true, numericality: {only_integer: true} + validates :wilson_score, presence:true, numericality: {greater_than:0, less_than:1} + validates :target, presence:true + end +end \ No newline at end of file diff --git a/ruby/lib/jam_ruby/models/user.rb b/ruby/lib/jam_ruby/models/user.rb index 217c73339..5e4e2ad22 100644 --- a/ruby/lib/jam_ruby/models/user.rb +++ b/ruby/lib/jam_ruby/models/user.rb @@ -45,6 +45,9 @@ module JamRuby # authorizations (for facebook, etc -- omniauth) has_many :user_authorizations, :class_name => "JamRuby::UserAuthorization" + has_many :reviews, :class_name => "JamRuby::Review" + has_many :review_summaries, :class_name => "JamRuby::ReviewSummary" + # calendars (for scheduling NOT in music_session) has_many :calendars, :class_name => "JamRuby::Calendar" diff --git a/ruby/spec/jam_ruby/models/review_spec.rb b/ruby/spec/jam_ruby/models/review_spec.rb new file mode 100644 index 000000000..b8dd67f4d --- /dev/null +++ b/ruby/spec/jam_ruby/models/review_spec.rb @@ -0,0 +1,86 @@ +require 'spec_helper' + +describe Review do + + shared_examples_for :review do |target, target_type| + before(:each) do + Review.delete_all + User.delete_all + @user = FactoryGirl.create(:user) + end + + after(:all) do + Review.delete_all + User.delete_all + end + + context "validates review" do + it "blank target" do + review = Review.create() + review.valid?.should be_false + review.errors[:target].should == ["can't be blank"] + end + + it "no rating" do + review = Review.create(target:target) + review.valid?.should be_false + review.errors[:rating].should include("can't be blank") + review.errors[:rating].should include("is not a number") + end + + it "no user" do + review = Review.create(target:target, rating:3) + review.valid?.should be_false + review.errors[:user].should include("can't be blank") + end + + it "complete" do + review = Review.create(target:target, rating:3, user:@user) + review.valid?.should be_true + end + end + + context "validates review summary" do + it "blank target" do + review_summary = ReviewSummary.create() + review_summary.valid?.should be_false + review_summary.errors[:target].should == ["can't be blank"] + end + + it "no rating" do + review_summary = ReviewSummary.create(target:target) + review_summary.valid?.should be_false + review_summary.errors[:target].should be_empty + review_summary.errors[:avg_rating].should include("can't be blank") + review_summary.errors[:avg_rating].should include("is not a number") + end + + it "no score" do + review_summary = ReviewSummary.create(target:target, avg_rating:3.2) + review_summary.valid?.should be_false + review_summary.errors[:target].should be_empty + review_summary.errors[:avg_rating].should be_empty + review_summary.errors[:wilson_score].should include("can't be blank") + review_summary.errors[:wilson_score].should include("is not a number") + end + + it "no count" do + review_summary = ReviewSummary.create(target:target, avg_rating:3.2, wilson_score:0.95) + review_summary.valid?.should be_false + review_summary.errors[:review_count].should include("can't be blank") + end + + it "complete" do + review_summary = ReviewSummary.create(target:target, avg_rating:3.2, wilson_score:0.95, review_count: 15) + puts "complete:: #{review_summary.errors.inspect}" + review_summary.valid?.should be_true + end + end + end + + describe "with a jamtrack" do + @jam_track = FactoryGirl.create(:jam_track) + it_behaves_like :review, @jam_track, "jam_track" + end + +end \ No newline at end of file From 0beb386beadcc366159a964564218fa20243ff97 Mon Sep 17 00:00:00 2001 From: Steven Miers Date: Mon, 20 Jul 2015 11:29:19 -0500 Subject: [PATCH 002/591] VRFS-3316 : Review Uniqueness validation and spec. --- ruby/lib/jam_ruby/models/review.rb | 7 +++++++ ruby/spec/jam_ruby/models/review_spec.rb | 10 +++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/ruby/lib/jam_ruby/models/review.rb b/ruby/lib/jam_ruby/models/review.rb index 454e03943..bdddb6d73 100644 --- a/ruby/lib/jam_ruby/models/review.rb +++ b/ruby/lib/jam_ruby/models/review.rb @@ -6,8 +6,15 @@ module JamRuby belongs_to :deleted_by_user, foreign_key: 'deleted_by_user_id', class_name: "JamRuby::User" validates :rating, presence:true, numericality: {only_integer: true, minimum:1, maximum:5} + validates :target, presence:true validates :user, presence:true + validates :target_id, uniqueness: {scope: :user_id, message: "There is already a review for this User and Target."} + # # @options - can contain values: + # # * target_id (optional) + # def reduce(options) + # arel = Review.where("deleted_at=?", nil) + # end end end \ No newline at end of file diff --git a/ruby/spec/jam_ruby/models/review_spec.rb b/ruby/spec/jam_ruby/models/review_spec.rb index b8dd67f4d..622b688aa 100644 --- a/ruby/spec/jam_ruby/models/review_spec.rb +++ b/ruby/spec/jam_ruby/models/review_spec.rb @@ -35,9 +35,17 @@ describe Review do end it "complete" do - review = Review.create(target:target, rating:3, user:@user) + review = Review.create(target:target, rating:3, user:@user) review.valid?.should be_true end + + it "unique" do + review = Review.create(target:target, rating:3, user:@user) + review.valid?.should be_true + + review2 = Review.create(target:target, rating:3, user:@user) + review2.valid?.should be_false + end end context "validates review summary" do From 4b52d97a64cc3d8ff8acf34bea153d367eeef041 Mon Sep 17 00:00:00 2001 From: Steven Miers Date: Mon, 20 Jul 2015 11:35:16 -0500 Subject: [PATCH 003/591] VRFS-3316 : Review summary uniqueness validation and spec. --- ruby/lib/jam_ruby/models/review_summary.rb | 4 ++-- ruby/spec/jam_ruby/models/review_spec.rb | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/ruby/lib/jam_ruby/models/review_summary.rb b/ruby/lib/jam_ruby/models/review_summary.rb index 77e25d0e7..6e0ef6b11 100644 --- a/ruby/lib/jam_ruby/models/review_summary.rb +++ b/ruby/lib/jam_ruby/models/review_summary.rb @@ -2,11 +2,11 @@ module JamRuby class ReviewSummary < ActiveRecord::Base attr_accessible :target, :target_type, :avg_rating, :wilson_score, :review_count belongs_to :target, polymorphic: true - belongs_to :user, foreign_key: 'user_id', class_name: "JamRuby::User" - + validates :avg_rating, presence:true, numericality: true validates :review_count, presence:true, numericality: {only_integer: true} validates :wilson_score, presence:true, numericality: {greater_than:0, less_than:1} validates :target, presence:true + validates :target_id, uniqueness:true end end \ No newline at end of file diff --git a/ruby/spec/jam_ruby/models/review_spec.rb b/ruby/spec/jam_ruby/models/review_spec.rb index 622b688aa..e9fe640f9 100644 --- a/ruby/spec/jam_ruby/models/review_spec.rb +++ b/ruby/spec/jam_ruby/models/review_spec.rb @@ -83,6 +83,14 @@ describe Review do puts "complete:: #{review_summary.errors.inspect}" review_summary.valid?.should be_true end + + it "unique" do + review = ReviewSummary.create(target:target, avg_rating:3, wilson_score:0.82, review_count:14) + review.valid?.should be_true + + review2 = ReviewSummary.create(target:target, avg_rating:3.22, wilson_score:0.91, review_count:12) + review2.valid?.should be_false + end end end From 2c594bf7834b41332adf2d91df31cf86919c9843 Mon Sep 17 00:00:00 2001 From: Steven Miers Date: Mon, 20 Jul 2015 16:49:22 -0500 Subject: [PATCH 004/591] VRFS-3316 : Reduce method to roll up reviews * Creates review_summaries * Calculate and store wilson score * Unit tests --- ruby/lib/jam_ruby/models/review.rb | 36 ++++++++++++++++++---- ruby/lib/jam_ruby/models/review_summary.rb | 7 +++-- ruby/spec/factories.rb | 11 +++++++ ruby/spec/jam_ruby/models/review_spec.rb | 28 ++++++++++++++++- 4 files changed, 72 insertions(+), 10 deletions(-) diff --git a/ruby/lib/jam_ruby/models/review.rb b/ruby/lib/jam_ruby/models/review.rb index bdddb6d73..4e7726acc 100644 --- a/ruby/lib/jam_ruby/models/review.rb +++ b/ruby/lib/jam_ruby/models/review.rb @@ -10,11 +10,35 @@ module JamRuby validates :target, presence:true validates :user, presence:true validates :target_id, uniqueness: {scope: :user_id, message: "There is already a review for this User and Target."} - - # # @options - can contain values: - # # * target_id (optional) - # def reduce(options) - # arel = Review.where("deleted_at=?", nil) - # end + + class << self + # Create review_summary records by grouping reviews + def reduce() + ReviewSummary.transaction do + ReviewSummary.destroy_all + Review.select("target_id, target_type AS target_type, AVG(rating) as avg_rating, count(*) as review_count, SUM(CASE WHEN rating>=3.0 THEN 1 ELSE 0 END) AS pos_count").group("target_type, target_id") + .each do |r| + #puts "Reducing reviews: #{r.inspect} #{r.pos_count} / #{r.review_count}" + ReviewSummary.create!( + target_id: r.target_id, + target_type: r.target_type, + avg_rating: r.avg_rating, + wilson_score: ci_lower_bound(r.pos_count, r.review_count), + review_count: r.review_count + ) + end # each + end # transaction + end # reduce + + def ci_lower_bound(pos, n, confidence=0.95) + pos=pos.to_f + n=n.to_f + return 0 if n == 0 + z = 1.96 # Statistics2.pnormaldist(1-(1-confidence)/2) + phat = 1.0*pos/n + (phat + z*z/(2*n) - z * Math.sqrt((phat*(1-phat)+z*z/(4*n))/n))/(1+z*z/n) + end + + end # self end end \ No newline at end of file diff --git a/ruby/lib/jam_ruby/models/review_summary.rb b/ruby/lib/jam_ruby/models/review_summary.rb index 6e0ef6b11..94a21e901 100644 --- a/ruby/lib/jam_ruby/models/review_summary.rb +++ b/ruby/lib/jam_ruby/models/review_summary.rb @@ -1,12 +1,13 @@ module JamRuby class ReviewSummary < ActiveRecord::Base - attr_accessible :target, :target_type, :avg_rating, :wilson_score, :review_count + attr_accessible :target, :target_id, :target_type, :avg_rating, :wilson_score, :review_count belongs_to :target, polymorphic: true validates :avg_rating, presence:true, numericality: true validates :review_count, presence:true, numericality: {only_integer: true} validates :wilson_score, presence:true, numericality: {greater_than:0, less_than:1} - validates :target, presence:true - validates :target_id, uniqueness:true + validates :target_id, presence:true, uniqueness:true + + end end \ No newline at end of file diff --git a/ruby/spec/factories.rb b/ruby/spec/factories.rb index 90cd48c44..893a09c17 100644 --- a/ruby/spec/factories.rb +++ b/ruby/spec/factories.rb @@ -139,6 +139,17 @@ FactoryGirl.define do end end + factory :review, :class => JamRuby::Review do + sequence(:name) { |n| "Band" } + biography "My Biography" + city "Apex" + state "NC" + country "US" + before(:create) { |review| + review.genres << Genre.first + } + end + factory :music_session, :class => JamRuby::MusicSession do sequence(:name) { |n| "Music Session #{n}" } sequence(:description) { |n| "Music Session Description #{n}" } diff --git a/ruby/spec/jam_ruby/models/review_spec.rb b/ruby/spec/jam_ruby/models/review_spec.rb index e9fe640f9..dcdced01b 100644 --- a/ruby/spec/jam_ruby/models/review_spec.rb +++ b/ruby/spec/jam_ruby/models/review_spec.rb @@ -46,8 +46,33 @@ describe Review do review2 = Review.create(target:target, rating:3, user:@user) review2.valid?.should be_false end - end + it "reduces" do + review = Review.create(target:target, rating:3, user:@user) + review.valid?.should be_true + + review2 = Review.create(target:target, rating:5, user:FactoryGirl.create(:user)) + review2.valid?.should be_true + Review.count.should eq(2) + ReviewSummary.count.should eq(0) + Review.reduce() + ReviewSummary.count.should eq(1) + ReviewSummary.first.avg_rating.should eq(4.0) + + puts "ORIG: #{ReviewSummary.all.inspect}" + ws_orig = ReviewSummary.first.wilson_score + avg_orig = ReviewSummary.first.avg_rating + + 5.times {Review.create(target:target, rating:5, user:FactoryGirl.create(:user))} + Review.reduce() + + ReviewSummary.first.wilson_score.should > ws_orig + ReviewSummary.first.avg_rating.should > avg_orig + + puts "ALL: #{ReviewSummary.all.inspect}" + end + end # context + context "validates review summary" do it "blank target" do review_summary = ReviewSummary.create() @@ -99,4 +124,5 @@ describe Review do it_behaves_like :review, @jam_track, "jam_track" end + end \ No newline at end of file From 74b8c81a8afabf6ea2df56a9872014299791502d Mon Sep 17 00:00:00 2001 From: Steven Miers Date: Mon, 20 Jul 2015 19:11:52 -0500 Subject: [PATCH 005/591] VRFS-3316 : Review query method and spec to verify. --- ruby/lib/jam_ruby/models/review.rb | 2 +- ruby/lib/jam_ruby/models/review_summary.rb | 29 ++++++++++++ ruby/spec/jam_ruby/models/review_spec.rb | 53 +++++++++++++++++++++- 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/ruby/lib/jam_ruby/models/review.rb b/ruby/lib/jam_ruby/models/review.rb index 4e7726acc..d7e6c07d1 100644 --- a/ruby/lib/jam_ruby/models/review.rb +++ b/ruby/lib/jam_ruby/models/review.rb @@ -12,7 +12,7 @@ module JamRuby validates :target_id, uniqueness: {scope: :user_id, message: "There is already a review for this User and Target."} class << self - # Create review_summary records by grouping reviews + # Create review_summary records by grouping reviews def reduce() ReviewSummary.transaction do ReviewSummary.destroy_all diff --git a/ruby/lib/jam_ruby/models/review_summary.rb b/ruby/lib/jam_ruby/models/review_summary.rb index 94a21e901..f66e3aacd 100644 --- a/ruby/lib/jam_ruby/models/review_summary.rb +++ b/ruby/lib/jam_ruby/models/review_summary.rb @@ -8,6 +8,35 @@ module JamRuby validates :wilson_score, presence:true, numericality: {greater_than:0, less_than:1} validates :target_id, presence:true, uniqueness:true + class << self + # Query review_summaries using target type, id, and minimum review count + # * target_type: Only return review summaries for given target type + # * target_id: Only return review summary for given target type + # * minimum_reviews: Only return review summary made up of at least this many reviews + # * arel: start with pre-queried reviews (arel object) + # sorts by wilson score + def index(options={}) + if (options.key?(:arel)) + arel = options[:arel].order("wilson_score DESC") + else + arel = ReviewSummary.order("wilson_score DESC") + end + + if (options.key?(:target_type)) + arel = arel.where("target_type=?", options[:target_type]) + end + + if (options.key?(:target_id)) + arel = arel.where("target_id=?", options[:target_id]) + end + + if (options.key?(:minimum_reviews)) + arel = arel.where("review_count>=?", options[:minimum_reviews]) + end + + arel + end + end end end \ No newline at end of file diff --git a/ruby/spec/jam_ruby/models/review_spec.rb b/ruby/spec/jam_ruby/models/review_spec.rb index dcdced01b..656babb49 100644 --- a/ruby/spec/jam_ruby/models/review_spec.rb +++ b/ruby/spec/jam_ruby/models/review_spec.rb @@ -54,6 +54,8 @@ describe Review do review2 = Review.create(target:target, rating:5, user:FactoryGirl.create(:user)) review2.valid?.should be_true Review.count.should eq(2) + + # Reduce and check: ReviewSummary.count.should eq(0) Review.reduce() ReviewSummary.count.should eq(1) @@ -63,6 +65,7 @@ describe Review do ws_orig = ReviewSummary.first.wilson_score avg_orig = ReviewSummary.first.avg_rating + # Create some more and verify: 5.times {Review.create(target:target, rating:5, user:FactoryGirl.create(:user))} Review.reduce() @@ -72,12 +75,12 @@ describe Review do puts "ALL: #{ReviewSummary.all.inspect}" end end # context - + context "validates review summary" do it "blank target" do review_summary = ReviewSummary.create() review_summary.valid?.should be_false - review_summary.errors[:target].should == ["can't be blank"] + review_summary.errors[:target_id].should == ["can't be blank"] end it "no rating" do @@ -116,6 +119,52 @@ describe Review do review2 = ReviewSummary.create(target:target, avg_rating:3.22, wilson_score:0.91, review_count:12) review2.valid?.should be_false end + + it "reduces and queries" do + review = Review.create(target:target, rating:3, user:@user) + review.valid?.should be_true + review2 = Review.create(target:target, rating:5, user:FactoryGirl.create(:user)) + review2.valid?.should be_true + Review.count.should eq(2) + + # Reduce and check: + ReviewSummary.count.should eq(0) + Review.reduce() + ReviewSummary.count.should eq(1) + ReviewSummary.first.avg_rating.should eq(4.0) + + puts "ORIG: #{ReviewSummary.all.inspect}" + ws_orig = ReviewSummary.first.wilson_score + avg_orig = ReviewSummary.first.avg_rating + + + # Create some more and verify: + 5.times {Review.create(target:target, rating:5, user:FactoryGirl.create(:user))} + Review.reduce() + ReviewSummary.count.should eq(1) + ReviewSummary.first.wilson_score.should > ws_orig + ReviewSummary.first.avg_rating.should > avg_orig + + + # Create some more with a different target and verify: + target2=FactoryGirl.create(:jam_track) + 5.times {Review.create(target:target2, rating:5, user:FactoryGirl.create(:user))} + Review.reduce() + summaries = ReviewSummary.index() + summaries.count.should eq(2) + summaries[0].wilson_score.should > summaries[1].wilson_score + + summaries = ReviewSummary.index(target_id: target2) + puts "MULTIPLE TARGET ALL: #{summaries.inspect}" + summaries.count.should eq(1) + summaries[0].target_id.should eq(target2.id) + + summaries = ReviewSummary.index(target_type: "JamRuby::JamTrack") + summaries.count.should eq(2) + + summaries = ReviewSummary.index(minimum_reviews: 6) + summaries.count.should eq(1) + end end end From bd4b3380c26efabcd7019761fba8fc8b39bbd5f9 Mon Sep 17 00:00:00 2001 From: Steven Miers Date: Mon, 20 Jul 2015 20:33:20 -0500 Subject: [PATCH 006/591] VRFS-3316 : Review query method and spec to verify. --- ruby/lib/jam_ruby/models/review.rb | 40 ++++++++++++++++++------ ruby/spec/jam_ruby/models/review_spec.rb | 27 +++++++++------- 2 files changed, 46 insertions(+), 21 deletions(-) diff --git a/ruby/lib/jam_ruby/models/review.rb b/ruby/lib/jam_ruby/models/review.rb index d7e6c07d1..41b589004 100644 --- a/ruby/lib/jam_ruby/models/review.rb +++ b/ruby/lib/jam_ruby/models/review.rb @@ -12,20 +12,40 @@ module JamRuby validates :target_id, uniqueness: {scope: :user_id, message: "There is already a review for this User and Target."} class << self + def index(options={}) + if(options.key?(:include_deleted)) + arel = Review.select("*") + else + arel = Review.where("deleted_at IS NULL") + end + + if(options.key?(:target_id)) + arel = arel.where("target_id=?", options[:target_id]) + end + + if(options.key?(:user_id)) + arel = arel.where("user_id=?", options[:user_id]) + end + + arel + end + # Create review_summary records by grouping reviews def reduce() ReviewSummary.transaction do ReviewSummary.destroy_all - Review.select("target_id, target_type AS target_type, AVG(rating) as avg_rating, count(*) as review_count, SUM(CASE WHEN rating>=3.0 THEN 1 ELSE 0 END) AS pos_count").group("target_type, target_id") - .each do |r| - #puts "Reducing reviews: #{r.inspect} #{r.pos_count} / #{r.review_count}" - ReviewSummary.create!( - target_id: r.target_id, - target_type: r.target_type, - avg_rating: r.avg_rating, - wilson_score: ci_lower_bound(r.pos_count, r.review_count), - review_count: r.review_count - ) + Review.select("target_id, target_type AS target_type, AVG(rating) as avg_rating, count(*) as review_count, SUM(CASE WHEN rating>=3.0 THEN 1 ELSE 0 END) AS pos_count") + .where("deleted_at IS NULL") + .group("target_type, target_id") + .each do |r| + wilson_score=ci_lower_bound(r.pos_count, r.review_count) + ReviewSummary.create!( + target_id: r.target_id, + target_type: r.target_type, + avg_rating: r.avg_rating, + wilson_score: wilson_score, + review_count: r.review_count + ) end # each end # transaction end # reduce diff --git a/ruby/spec/jam_ruby/models/review_spec.rb b/ruby/spec/jam_ruby/models/review_spec.rb index 656babb49..ace9d2dc1 100644 --- a/ruby/spec/jam_ruby/models/review_spec.rb +++ b/ruby/spec/jam_ruby/models/review_spec.rb @@ -53,12 +53,13 @@ describe Review do review2 = Review.create(target:target, rating:5, user:FactoryGirl.create(:user)) review2.valid?.should be_true - Review.count.should eq(2) + Review.should have(2).items + Review.index.should have(2).items # Reduce and check: - ReviewSummary.count.should eq(0) + ReviewSummary.should have(0).items Review.reduce() - ReviewSummary.count.should eq(1) + ReviewSummary.should have(1).items ReviewSummary.first.avg_rating.should eq(4.0) puts "ORIG: #{ReviewSummary.all.inspect}" @@ -67,6 +68,8 @@ describe Review do # Create some more and verify: 5.times {Review.create(target:target, rating:5, user:FactoryGirl.create(:user))} + Review.index.should have(7).items + Review.reduce() ReviewSummary.first.wilson_score.should > ws_orig @@ -125,12 +128,12 @@ describe Review do review.valid?.should be_true review2 = Review.create(target:target, rating:5, user:FactoryGirl.create(:user)) review2.valid?.should be_true - Review.count.should eq(2) + Review.should have(2).items # Reduce and check: - ReviewSummary.count.should eq(0) + ReviewSummary.should have(0).items Review.reduce() - ReviewSummary.count.should eq(1) + ReviewSummary.should have(1).items ReviewSummary.first.avg_rating.should eq(4.0) puts "ORIG: #{ReviewSummary.all.inspect}" @@ -141,7 +144,7 @@ describe Review do # Create some more and verify: 5.times {Review.create(target:target, rating:5, user:FactoryGirl.create(:user))} Review.reduce() - ReviewSummary.count.should eq(1) + ReviewSummary.should have(1).items ReviewSummary.first.wilson_score.should > ws_orig ReviewSummary.first.avg_rating.should > avg_orig @@ -149,21 +152,23 @@ describe Review do # Create some more with a different target and verify: target2=FactoryGirl.create(:jam_track) 5.times {Review.create(target:target2, rating:5, user:FactoryGirl.create(:user))} + Review.index.should have(12).items + Review.index(target_id: target2).should have(5).items Review.reduce() summaries = ReviewSummary.index() - summaries.count.should eq(2) + summaries.should have(2).items summaries[0].wilson_score.should > summaries[1].wilson_score summaries = ReviewSummary.index(target_id: target2) puts "MULTIPLE TARGET ALL: #{summaries.inspect}" - summaries.count.should eq(1) + summaries.should have(1).items summaries[0].target_id.should eq(target2.id) summaries = ReviewSummary.index(target_type: "JamRuby::JamTrack") - summaries.count.should eq(2) + summaries.should have(2).items summaries = ReviewSummary.index(minimum_reviews: 6) - summaries.count.should eq(1) + summaries.should have(1).items end end end From 1262dbfcc6847e024f855ac57c0581a2972b00f4 Mon Sep 17 00:00:00 2001 From: Steven Miers Date: Tue, 21 Jul 2015 14:47:35 -0500 Subject: [PATCH 007/591] VRFS-3316 : Reviews : routes and api controller * Expose review query methods as an API/rest interface. * Required updates to model * Spec to verify --- ruby/lib/jam_ruby/models/review.rb | 4 +- ruby/lib/jam_ruby/models/review_summary.rb | 1 + ruby/spec/factories.rb | 13 +- ruby/spec/jam_ruby/models/review_spec.rb | 2 +- web/app/controllers/api_reviews_controller.rb | 63 +++++++++ web/config/routes.rb | 6 + .../api_reviews_controller_spec.rb | 122 ++++++++++++++++++ 7 files changed, 196 insertions(+), 15 deletions(-) create mode 100644 web/app/controllers/api_reviews_controller.rb create mode 100644 web/spec/controllers/api_reviews_controller_spec.rb diff --git a/ruby/lib/jam_ruby/models/review.rb b/ruby/lib/jam_ruby/models/review.rb index 41b589004..4c1031b3d 100644 --- a/ruby/lib/jam_ruby/models/review.rb +++ b/ruby/lib/jam_ruby/models/review.rb @@ -1,6 +1,6 @@ module JamRuby class Review < ActiveRecord::Base - attr_accessible :target, :rating, :description, :user + attr_accessible :target, :rating, :description, :user, :user_id, :target_id, :target_type belongs_to :target, polymorphic: true belongs_to :user, foreign_key: 'user_id', class_name: "JamRuby::User" belongs_to :deleted_by_user, foreign_key: 'deleted_by_user_id', class_name: "JamRuby::User" @@ -8,7 +8,7 @@ module JamRuby validates :rating, presence:true, numericality: {only_integer: true, minimum:1, maximum:5} validates :target, presence:true - validates :user, presence:true + validates :user_id, presence:true validates :target_id, uniqueness: {scope: :user_id, message: "There is already a review for this User and Target."} class << self diff --git a/ruby/lib/jam_ruby/models/review_summary.rb b/ruby/lib/jam_ruby/models/review_summary.rb index f66e3aacd..fb3833f5d 100644 --- a/ruby/lib/jam_ruby/models/review_summary.rb +++ b/ruby/lib/jam_ruby/models/review_summary.rb @@ -17,6 +17,7 @@ module JamRuby # * arel: start with pre-queried reviews (arel object) # sorts by wilson score def index(options={}) + options ||= {} if (options.key?(:arel)) arel = options[:arel].order("wilson_score DESC") else diff --git a/ruby/spec/factories.rb b/ruby/spec/factories.rb index 893a09c17..bf45bdc20 100644 --- a/ruby/spec/factories.rb +++ b/ruby/spec/factories.rb @@ -138,18 +138,7 @@ FactoryGirl.define do end end end - - factory :review, :class => JamRuby::Review do - sequence(:name) { |n| "Band" } - biography "My Biography" - city "Apex" - state "NC" - country "US" - before(:create) { |review| - review.genres << Genre.first - } - end - + factory :music_session, :class => JamRuby::MusicSession do sequence(:name) { |n| "Music Session #{n}" } sequence(:description) { |n| "Music Session Description #{n}" } diff --git a/ruby/spec/jam_ruby/models/review_spec.rb b/ruby/spec/jam_ruby/models/review_spec.rb index ace9d2dc1..2b62bba89 100644 --- a/ruby/spec/jam_ruby/models/review_spec.rb +++ b/ruby/spec/jam_ruby/models/review_spec.rb @@ -31,7 +31,7 @@ describe Review do it "no user" do review = Review.create(target:target, rating:3) review.valid?.should be_false - review.errors[:user].should include("can't be blank") + review.errors[:user_id].should include("can't be blank") end it "complete" do diff --git a/web/app/controllers/api_reviews_controller.rb b/web/app/controllers/api_reviews_controller.rb new file mode 100644 index 000000000..83f8fed45 --- /dev/null +++ b/web/app/controllers/api_reviews_controller.rb @@ -0,0 +1,63 @@ +require 'sanitize' +class ApiReviewsController < ApiController + + before_filter :api_signed_in_user, :except => [:index] + #before_filter :auth_user, :only => [:create, :update, :delete] + before_filter :lookup_review_summary, :only => [:details] + before_filter :lookup_review, :only => [:update, :delete, :show] + + respond_to :json + + def index + summaries = ReviewSummary.index(params[:review]) + @reviews = summaries.paginate(page: params[:page], per_page: params[:per_page]) + respond_with @reviews, responder: ApiResponder, :status => 200 + end + + # Create a review: + def create + @review = Review.new + @review.target_id = params[:target_id] + @review.user = current_user + @review.rating = params[:rating] + @review.description = params[:description] + @review.target_type = params[:target_type] + @review.save + respond_with_model(@review) + end + + def details + reviews = Review.index(:target_id=>@review_summary.target_id) + @reviews = reviews.paginate(page: params[:page], per_page: params[:per_page]) + respond_with @reviews, responder: ApiResponder, :status => 200 + end + + def update + mods = params[:mods] + if mods.present? + @review.rating = mods[:rating] if mods.key?(:rating) + @review.description = mods[:description] if mods.key?(:description) + @review.save + end + respond_with_model(@review) + end + + def delete + @review.deleted_at = Time.now() + @review.save + render :json => {}, status: 204 + end + +private + + def lookup_review_summary + @review_summary = ReviewSummary.find(params[:review_summary_id]) + end + + def lookup_review + arel = Review.where("id=?", params[:id]) + arel = arel.where("user_id=?", current_user) unless current_user.admin + @review = arel.first + puts "FFFound: #{@review}" + end +end diff --git a/web/config/routes.rb b/web/config/routes.rb index 2281f6937..34711060e 100644 --- a/web/config/routes.rb +++ b/web/config/routes.rb @@ -288,6 +288,12 @@ SampleApp::Application.routes.draw do match '/users/complete/:signup_token' => 'api_users#complete', as: 'complete', via: 'post' match '/users/:id/set_password' => 'api_users#set_password', :via => :post + match '/reviews' => 'api_reviews#index', :via => :get + match '/reviews' => 'api_reviews#create', :via => :post + match '/reviews/:id' => 'api_reviews#update', :via => :post + match '/reviews/:id' => 'api_reviews#delete', :via => :delete + match '/reviews/details/:review_summary_id' => 'api_users#details', :via => :get, :as => 'api_summary_reviews' + # recurly match '/recurly/create_account' => 'api_recurly#create_account', :via => :post match '/recurly/delete_account' => 'api_recurly#delete_account', :via => :delete diff --git a/web/spec/controllers/api_reviews_controller_spec.rb b/web/spec/controllers/api_reviews_controller_spec.rb new file mode 100644 index 000000000..d129a45fb --- /dev/null +++ b/web/spec/controllers/api_reviews_controller_spec.rb @@ -0,0 +1,122 @@ +require 'spec_helper' + +describe ApiReviewsController do + render_views + + + before(:all) do + @logged_in_user = FactoryGirl.create(:user) + end + + before(:each) do + Review.destroy_all + ReviewSummary.destroy_all + @user= FactoryGirl.create(:user) + @target= FactoryGirl.create(:jam_track) + controller.current_user = @logged_in_user + end + + after(:all) do + Review.destroy_all + ReviewSummary.destroy_all + User.destroy_all + JamTrack.destroy_all + end + + describe "create" do + it "successful" do + post :create, rating:3, description:"it was ok", target_id: @target.id, target_type:"JamRuby::JamTrack", format: 'json' + response.should be_success + Review.index.should have(1).items + end + end + + describe "update" do + before :each do + @review=Review.create!(target:@target, rating:5, description: "blah", user_id: @logged_in_user.id) + end + it "basic" do + post :update, id:@review.id, mods: {rating:4, description: "not blah"}, :format=>'json' + response.should be_success + @review.reload + @review.rating.should eq(4) + @review.description.should eq("not blah") + end + end + + describe "delete" do + before :each do + @review=Review.create!(target:@target, rating:5, description: "blah", user_id: @logged_in_user.id) + end + it "marks review as deleted" do + delete :delete, id:@review.id + response.should be_success + Review.index.should have(0).items + Review.index(include_deleted:true).should have(1).items + end + end + + describe "indexes" do + before :each do + @target2=FactoryGirl.create(:jam_track) + + 7.times { Review.create!(target:@target, rating:4, description: "blah", user_id: FactoryGirl.create(:user).id) } + 5.times { Review.create!(target:@target2, rating:4, description: "blah", user_id: FactoryGirl.create(:user).id) } + end + + it "review summaries" do + get :index, format: 'json' + response.should be_success + json = JSON.parse(response.body) + json.should have(0).items + json = JSON.parse(response.body) + puts "response.inspect: #{JSON.pretty_generate(json)}" + + ReviewSummary.index.should have(0).items + Review.reduce() + ReviewSummary.index.should have(2).items + get :index, format: 'json' + json = JSON.parse(response.body) + json.should have(2).items + puts "response.inspect: #{JSON.pretty_generate(json)}" + + end + + it "details" do + ReviewSummary.index.should have(0).items + Review.reduce() + ReviewSummary.index.should have(2).items + + + summaries = ReviewSummary.index + get :details, :review_summary_id=>summaries[0].id, format: 'json' + response.should be_success + json = JSON.parse(response.body) + json.should have(7).items + + get :details, :review_summary_id=>summaries[1].id, format: 'json' + response.should be_success + json = JSON.parse(response.body) + json.should have(5).items + + puts "details: #{JSON.pretty_generate(json)}" + end + + it "paginates details" do + ReviewSummary.index.should have(0).items + Review.reduce() + summaries = ReviewSummary.index + summaries.should have(2).items + + get :details, review_summary_id:summaries[0].id, page: 1, per_page: 3, format: 'json' + response.should be_success + json = JSON.parse(response.body) + json.should have(3).items + + get :details, review_summary_id:summaries[0].id, page: 3, per_page: 3, format: 'json' + response.should be_success + json = JSON.parse(response.body) + json.should have(1).items + end + end +end From 2d4ce4cf3c24ed7fcc18e5ffe4e612a0dc9a1cff Mon Sep 17 00:00:00 2001 From: Steven Miers Date: Tue, 21 Jul 2015 16:55:40 -0500 Subject: [PATCH 008/591] VRFS-3316 : Review cleanup, new tests --- web/app/controllers/api_reviews_controller.rb | 9 ++++++--- .../api_reviews_controller_spec.rb | 20 +++++++++---------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/web/app/controllers/api_reviews_controller.rb b/web/app/controllers/api_reviews_controller.rb index 83f8fed45..a7ace92af 100644 --- a/web/app/controllers/api_reviews_controller.rb +++ b/web/app/controllers/api_reviews_controller.rb @@ -1,13 +1,12 @@ require 'sanitize' class ApiReviewsController < ApiController - before_filter :api_signed_in_user, :except => [:index] - #before_filter :auth_user, :only => [:create, :update, :delete] before_filter :lookup_review_summary, :only => [:details] before_filter :lookup_review, :only => [:update, :delete, :show] respond_to :json + # List review summaries according to params: def index summaries = ReviewSummary.index(params[:review]) @reviews = summaries.paginate(page: params[:page], per_page: params[:per_page]) @@ -26,12 +25,14 @@ class ApiReviewsController < ApiController respond_with_model(@review) end + # List reviews matching targets for given review summary: def details reviews = Review.index(:target_id=>@review_summary.target_id) @reviews = reviews.paginate(page: params[:page], per_page: params[:per_page]) respond_with @reviews, responder: ApiResponder, :status => 200 end + # Update a review: def update mods = params[:mods] if mods.present? @@ -42,8 +43,10 @@ class ApiReviewsController < ApiController respond_with_model(@review) end + # Mark a review as deleted: def delete @review.deleted_at = Time.now() + @review @review.save render :json => {}, status: 204 end @@ -58,6 +61,6 @@ private arel = Review.where("id=?", params[:id]) arel = arel.where("user_id=?", current_user) unless current_user.admin @review = arel.first - puts "FFFound: #{@review}" + raise ActiveRecord::RecordNotFound, "Couldn't find review matching #{arel}" if @review.nil? end end diff --git a/web/spec/controllers/api_reviews_controller_spec.rb b/web/spec/controllers/api_reviews_controller_spec.rb index d129a45fb..29a670c28 100644 --- a/web/spec/controllers/api_reviews_controller_spec.rb +++ b/web/spec/controllers/api_reviews_controller_spec.rb @@ -2,8 +2,6 @@ require 'spec_helper' describe ApiReviewsController do render_views - - before(:all) do @logged_in_user = FactoryGirl.create(:user) end @@ -35,6 +33,7 @@ describe ApiReviewsController do before :each do @review=Review.create!(target:@target, rating:5, description: "blah", user_id: @logged_in_user.id) end + it "basic" do post :update, id:@review.id, mods: {rating:4, description: "not blah"}, :format=>'json' response.should be_success @@ -42,12 +41,18 @@ describe ApiReviewsController do @review.rating.should eq(4) @review.description.should eq("not blah") end + + it "bad identifier" do + post :update, id:2112, mods: {rating:4, description: "not blah"}, :format=>'json' + response.status.should eql(404) + end end describe "delete" do before :each do @review=Review.create!(target:@target, rating:5, description: "blah", user_id: @logged_in_user.id) end + it "marks review as deleted" do delete :delete, id:@review.id response.should be_success @@ -68,18 +73,14 @@ describe ApiReviewsController do get :index, format: 'json' response.should be_success json = JSON.parse(response.body) - json.should have(0).items - json = JSON.parse(response.body) - puts "response.inspect: #{JSON.pretty_generate(json)}" + json.should have(0).items ReviewSummary.index.should have(0).items Review.reduce() ReviewSummary.index.should have(2).items get :index, format: 'json' json = JSON.parse(response.body) - json.should have(2).items - puts "response.inspect: #{JSON.pretty_generate(json)}" - + json.should have(2).item end it "details" do @@ -87,7 +88,6 @@ describe ApiReviewsController do Review.reduce() ReviewSummary.index.should have(2).items - summaries = ReviewSummary.index get :details, :review_summary_id=>summaries[0].id, format: 'json' response.should be_success @@ -98,8 +98,6 @@ describe ApiReviewsController do response.should be_success json = JSON.parse(response.body) json.should have(5).items - - puts "details: #{JSON.pretty_generate(json)}" end it "paginates details" do From 46cd9c47988afdf46f8f7b91294bd60c81a6c560 Mon Sep 17 00:00:00 2001 From: Steven Miers Date: Tue, 21 Jul 2015 18:10:45 -0500 Subject: [PATCH 009/591] Remove stray debug output. --- ruby/spec/jam_ruby/models/review_spec.rb | 5 ----- 1 file changed, 5 deletions(-) diff --git a/ruby/spec/jam_ruby/models/review_spec.rb b/ruby/spec/jam_ruby/models/review_spec.rb index 2b62bba89..a8cc13cc7 100644 --- a/ruby/spec/jam_ruby/models/review_spec.rb +++ b/ruby/spec/jam_ruby/models/review_spec.rb @@ -62,7 +62,6 @@ describe Review do ReviewSummary.should have(1).items ReviewSummary.first.avg_rating.should eq(4.0) - puts "ORIG: #{ReviewSummary.all.inspect}" ws_orig = ReviewSummary.first.wilson_score avg_orig = ReviewSummary.first.avg_rating @@ -75,7 +74,6 @@ describe Review do ReviewSummary.first.wilson_score.should > ws_orig ReviewSummary.first.avg_rating.should > avg_orig - puts "ALL: #{ReviewSummary.all.inspect}" end end # context @@ -111,7 +109,6 @@ describe Review do it "complete" do review_summary = ReviewSummary.create(target:target, avg_rating:3.2, wilson_score:0.95, review_count: 15) - puts "complete:: #{review_summary.errors.inspect}" review_summary.valid?.should be_true end @@ -136,7 +133,6 @@ describe Review do ReviewSummary.should have(1).items ReviewSummary.first.avg_rating.should eq(4.0) - puts "ORIG: #{ReviewSummary.all.inspect}" ws_orig = ReviewSummary.first.wilson_score avg_orig = ReviewSummary.first.avg_rating @@ -160,7 +156,6 @@ describe Review do summaries[0].wilson_score.should > summaries[1].wilson_score summaries = ReviewSummary.index(target_id: target2) - puts "MULTIPLE TARGET ALL: #{summaries.inspect}" summaries.should have(1).items summaries[0].target_id.should eq(target2.id) From 82aa6bb24439dbeb44e9b15e92d3f9c46f2d6a7f Mon Sep 17 00:00:00 2001 From: Steven Miers Date: Tue, 21 Jul 2015 19:14:18 -0500 Subject: [PATCH 010/591] Use scopes to clean up. --- ruby/lib/jam_ruby/models/review.rb | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ruby/lib/jam_ruby/models/review.rb b/ruby/lib/jam_ruby/models/review.rb index 4c1031b3d..cbd0648d3 100644 --- a/ruby/lib/jam_ruby/models/review.rb +++ b/ruby/lib/jam_ruby/models/review.rb @@ -5,6 +5,9 @@ module JamRuby belongs_to :user, foreign_key: 'user_id', class_name: "JamRuby::User" belongs_to :deleted_by_user, foreign_key: 'deleted_by_user_id', class_name: "JamRuby::User" + scope :available, -> { where("deleted_at iS NULL") } + scope :all, -> { select("*")} + validates :rating, presence:true, numericality: {only_integer: true, minimum:1, maximum:5} validates :target, presence:true @@ -14,9 +17,9 @@ module JamRuby class << self def index(options={}) if(options.key?(:include_deleted)) - arel = Review.select("*") + arel = Review.all else - arel = Review.where("deleted_at IS NULL") + arel = Review.available end if(options.key?(:target_id)) From 82dd27754c0e07d0d78f3d4de94e366f3a8b0ab9 Mon Sep 17 00:00:00 2001 From: Steven Miers Date: Wed, 22 Jul 2015 02:54:10 +0000 Subject: [PATCH 011/591] Tweak --- web/README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/web/README.md b/web/README.md index 393628cf1..f2dccf0a1 100644 --- a/web/README.md +++ b/web/README.md @@ -2,5 +2,3 @@ Jasmine Javascript Unit Tests ============================= Open browser to localhost:3000/teaspoon - - From cb3137ff8024ddb0fd3af63b1e3d6cf0feb9bfd9 Mon Sep 17 00:00:00 2001 From: Steven Miers Date: Fri, 24 Jul 2015 18:32:29 -0500 Subject: [PATCH 012/591] VRFS-3359 : Teacher Profile Migrations --- db/manifest | 1 + db/up/profile_teacher.sql | 73 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 db/up/profile_teacher.sql diff --git a/db/manifest b/db/manifest index 691ae0001..1c648aa07 100755 --- a/db/manifest +++ b/db/manifest @@ -298,3 +298,4 @@ musician_search.sql enhance_band_profile.sql alter_band_profile_rate_defaults.sql repair_band_profile.sql +profile_teacher.sql diff --git a/db/up/profile_teacher.sql b/db/up/profile_teacher.sql new file mode 100644 index 000000000..b2fed066e --- /dev/null +++ b/db/up/profile_teacher.sql @@ -0,0 +1,73 @@ +CREATE TABLE teachers ( + id VARCHAR(64) PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id VARCHAR(64) REFERENCES users(id) ON DELETE CASCADE, + introductory_video VARCHAR(1024) NULL, + years_teaching SMALLINT NOT NULL, + years_playing SMALLINT NOT NULL, + teaches_age_lower SMALLINT NOT NULL DEFAULT 0, + teaches_age_upper SMALLINT NOT NULL DEFAULT 0, + website VARCHAR(1024) NULL, + biography VARCHAR(4096) NOT NULL, + teaches_beginner BOOLEAN NOT NULL DEFAULT FALSE, + teaches_intermediat BOOLEAN NOT NULL DEFAULT FALSE, + teaches_advanced BOOLEAN NOT NULL DEFAULT FALSE, + price_per_lesson BOOLEAN NOT NULL DEFAULT FALSE, + price_per_month BOOLEAN NOT NULL DEFAULT FALSE, + lesson_duration_30 BOOLEAN NOT NULL DEFAULT FALSE, + lesson_duration_45 BOOLEAN NOT NULL DEFAULT FALSE, + lesson_duration_60 BOOLEAN NOT NULL DEFAULT FALSE, + lesson_duration_90 BOOLEAN NOT NULL DEFAULT FALSE, + lesson_duration_120 BOOLEAN NOT NULL DEFAULT FALSE, + price_per_lesson_cents INT NULL, + price_per_month_cents INT NULL, + lesson_duration_30_cents INT NULL, + lesson_duration_45_cents INT NULL, + lesson_duration_60_cents INT NULL, + lesson_duration_90_cents INT NULL, + lesson_duration_120_cents INT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE subjects( + id VARCHAR(64) PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(1024) NOT NULL, + description VARCHAR(4000) NULL +); + +CREATE TABLE languages( + id VARCHAR(64) PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(1024) NOT NULL, + description VARCHAR(4000) NULL +); + +-- Has many: +CREATE TABLE teacher_experience( + id VARCHAR(64) PRIMARY KEY DEFAULT uuid_generate_v4(), + teacher_id VARCHAR(64) REFERENCES teachers(id) ON DELETE CASCADE, + -- experience type: teaching, education, award: + experience_type VARCHAR(32) NOT NULL, + name VARCHAR(200) NOT NULL, + organization VARCHAR(200) NOT NULL, + start_year SMALLINT NOT NULL DEFAULT 0, + end_year SMALLINT NOT NULL DEFAULT 0 +); + +-- Has many/through tables: +CREATE TABLE teachers_genres( + teacher_id VARCHAR(64) REFERENCES teachers(id) ON DELETE CASCADE, + genre_id VARCHAR(64) REFERENCES genres(id) ON DELETE CASCADE +); +CREATE TABLE teachers_instruments( + teacher_id VARCHAR(64) REFERENCES teachers(id) ON DELETE CASCADE, + instrument_id VARCHAR(64) REFERENCES instruments(id) ON DELETE CASCADE +); +CREATE TABLE teachers_subjects( + teacher_id VARCHAR(64) REFERENCES teachers(id) ON DELETE CASCADE, + subject_id VARCHAR(64) REFERENCES subjects(id) ON DELETE CASCADE +); +CREATE TABLE teacher_languages( + teacher_id VARCHAR(64) REFERENCES teachers(id) ON DELETE CASCADE, + language_id VARCHAR(64) REFERENCES languages(id) ON DELETE CASCADE +); + From 460783a5aae6f985e995174e74b25520de55f65a Mon Sep 17 00:00:00 2001 From: Steven Miers Date: Mon, 27 Jul 2015 10:02:37 -0500 Subject: [PATCH 013/591] VRFS-3359 : Teacher models, relationships, some validations, and initial tests. --- db/up/profile_teacher.sql | 42 +++++++++---------- ruby/lib/jam_ruby.rb | 4 ++ ruby/lib/jam_ruby/models/genre.rb | 3 ++ ruby/lib/jam_ruby/models/instrument.rb | 3 ++ ruby/lib/jam_ruby/models/language.rb | 7 ++++ ruby/lib/jam_ruby/models/subject.rb | 7 ++++ ruby/lib/jam_ruby/models/teacher.rb | 38 +++++++++++++++++ .../lib/jam_ruby/models/teacher_experience.rb | 7 ++++ ruby/spec/jam_ruby/models/teacher_spec.rb | 37 ++++++++++++++++ 9 files changed, 127 insertions(+), 21 deletions(-) create mode 100644 ruby/lib/jam_ruby/models/language.rb create mode 100644 ruby/lib/jam_ruby/models/subject.rb create mode 100644 ruby/lib/jam_ruby/models/teacher.rb create mode 100644 ruby/lib/jam_ruby/models/teacher_experience.rb create mode 100644 ruby/spec/jam_ruby/models/teacher_spec.rb diff --git a/db/up/profile_teacher.sql b/db/up/profile_teacher.sql index b2fed066e..b8e0134a3 100644 --- a/db/up/profile_teacher.sql +++ b/db/up/profile_teacher.sql @@ -1,23 +1,23 @@ CREATE TABLE teachers ( id VARCHAR(64) PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id VARCHAR(64) REFERENCES users(id) ON DELETE CASCADE, - introductory_video VARCHAR(1024) NULL, - years_teaching SMALLINT NOT NULL, - years_playing SMALLINT NOT NULL, - teaches_age_lower SMALLINT NOT NULL DEFAULT 0, - teaches_age_upper SMALLINT NOT NULL DEFAULT 0, - website VARCHAR(1024) NULL, - biography VARCHAR(4096) NOT NULL, - teaches_beginner BOOLEAN NOT NULL DEFAULT FALSE, - teaches_intermediat BOOLEAN NOT NULL DEFAULT FALSE, - teaches_advanced BOOLEAN NOT NULL DEFAULT FALSE, - price_per_lesson BOOLEAN NOT NULL DEFAULT FALSE, - price_per_month BOOLEAN NOT NULL DEFAULT FALSE, - lesson_duration_30 BOOLEAN NOT NULL DEFAULT FALSE, - lesson_duration_45 BOOLEAN NOT NULL DEFAULT FALSE, - lesson_duration_60 BOOLEAN NOT NULL DEFAULT FALSE, - lesson_duration_90 BOOLEAN NOT NULL DEFAULT FALSE, - lesson_duration_120 BOOLEAN NOT NULL DEFAULT FALSE, + user_id VARCHAR(64) REFERENCES users(id) ON DELETE CASCADE, + introductory_video VARCHAR(1024) NULL, + years_teaching SMALLINT NOT NULL DEFAULT 0, + years_playing SMALLINT NOT NULL DEFAULT 0, + teaches_age_lower SMALLINT NOT NULL DEFAULT 0, + teaches_age_upper SMALLINT NOT NULL DEFAULT 0, + website VARCHAR(1024) NULL, + biography VARCHAR(4096) NULL, + teaches_beginner BOOLEAN NOT NULL DEFAULT FALSE, + teaches_intermediate BOOLEAN NOT NULL DEFAULT FALSE, + teaches_advanced BOOLEAN NOT NULL DEFAULT FALSE, + price_per_lesson BOOLEAN NOT NULL DEFAULT FALSE, + price_per_month BOOLEAN NOT NULL DEFAULT FALSE, + lesson_duration_30 BOOLEAN NOT NULL DEFAULT FALSE, + lesson_duration_45 BOOLEAN NOT NULL DEFAULT FALSE, + lesson_duration_60 BOOLEAN NOT NULL DEFAULT FALSE, + lesson_duration_90 BOOLEAN NOT NULL DEFAULT FALSE, + lesson_duration_120 BOOLEAN NOT NULL DEFAULT FALSE, price_per_lesson_cents INT NULL, price_per_month_cents INT NULL, lesson_duration_30_cents INT NULL, @@ -32,17 +32,17 @@ CREATE TABLE teachers ( CREATE TABLE subjects( id VARCHAR(64) PRIMARY KEY DEFAULT uuid_generate_v4(), name VARCHAR(1024) NOT NULL, - description VARCHAR(4000) NULL + description VARCHAR(1024) NULL ); CREATE TABLE languages( id VARCHAR(64) PRIMARY KEY DEFAULT uuid_generate_v4(), name VARCHAR(1024) NOT NULL, - description VARCHAR(4000) NULL + description VARCHAR(1024) NULL ); -- Has many: -CREATE TABLE teacher_experience( +CREATE TABLE teacher_experiences( id VARCHAR(64) PRIMARY KEY DEFAULT uuid_generate_v4(), teacher_id VARCHAR(64) REFERENCES teachers(id) ON DELETE CASCADE, -- experience type: teaching, education, award: diff --git a/ruby/lib/jam_ruby.rb b/ruby/lib/jam_ruby.rb index 82484997e..16ae29942 100755 --- a/ruby/lib/jam_ruby.rb +++ b/ruby/lib/jam_ruby.rb @@ -238,6 +238,10 @@ require "jam_ruby/models/performance_sample" require "jam_ruby/models/online_presence" require "jam_ruby/models/json_store" require "jam_ruby/models/musician_search" +require "jam_ruby/models/teacher" +require "jam_ruby/models/teacher_experience" +require "jam_ruby/models/language" +require "jam_ruby/models/subject" include Jampb diff --git a/ruby/lib/jam_ruby/models/genre.rb b/ruby/lib/jam_ruby/models/genre.rb index 1b0cd9ada..8ae195ffb 100644 --- a/ruby/lib/jam_ruby/models/genre.rb +++ b/ruby/lib/jam_ruby/models/genre.rb @@ -15,6 +15,9 @@ module JamRuby # genres has_and_belongs_to_many :recordings, :class_name => "JamRuby::Recording", :join_table => "recordings_genres" + # teachers + has_and_belongs_to_many :teachers, :class_name => "JamRuby::Teacher", :join_table => "teachers_genres" + # jam tracks has_many :jam_tracks, :class_name => "JamRuby::JamTrack" diff --git a/ruby/lib/jam_ruby/models/instrument.rb b/ruby/lib/jam_ruby/models/instrument.rb index 1a3fa8df7..cc6fe40a9 100644 --- a/ruby/lib/jam_ruby/models/instrument.rb +++ b/ruby/lib/jam_ruby/models/instrument.rb @@ -43,6 +43,9 @@ module JamRuby # music sessions has_and_belongs_to_many :music_sessions, :class_name => "JamRuby::ActiveMusicSession", :join_table => "genres_music_sessions" + # teachers + has_and_belongs_to_many :teachers, :class_name => "JamRuby::Teacher", :join_table => "teachers_instruments" + def self.standard_list return Instrument.where('instruments.popularity > 0').order('instruments.popularity DESC, instruments.description ASC') end diff --git a/ruby/lib/jam_ruby/models/language.rb b/ruby/lib/jam_ruby/models/language.rb new file mode 100644 index 000000000..bb5d64316 --- /dev/null +++ b/ruby/lib/jam_ruby/models/language.rb @@ -0,0 +1,7 @@ +module JamRuby + class Language < ActiveRecord::Base + include HtmlSanitize + html_sanitize strict: [:name, :description] + has_and_belongs_to_many :teachers, :class_name => "JamRuby::Teacher", :join_table => "teachers_languages" + end +end diff --git a/ruby/lib/jam_ruby/models/subject.rb b/ruby/lib/jam_ruby/models/subject.rb new file mode 100644 index 000000000..f20d75626 --- /dev/null +++ b/ruby/lib/jam_ruby/models/subject.rb @@ -0,0 +1,7 @@ +module JamRuby + class Subject < ActiveRecord::Base + include HtmlSanitize + html_sanitize strict: [:name, :description] + has_and_belongs_to_many :teachers, :class_name => "JamRuby::Teacher", :join_table => "teachers_subjects" + end +end diff --git a/ruby/lib/jam_ruby/models/teacher.rb b/ruby/lib/jam_ruby/models/teacher.rb new file mode 100644 index 000000000..431635cbd --- /dev/null +++ b/ruby/lib/jam_ruby/models/teacher.rb @@ -0,0 +1,38 @@ +module JamRuby + class Teacher < ActiveRecord::Base + include HtmlSanitize + html_sanitize strict: [:biography, :website] + has_and_belongs_to_many :genres, :class_name => "JamRuby::Genre", :join_table => "teachers_genres" + has_and_belongs_to_many :instruments, :class_name => "JamRuby::Instrument", :join_table => "teachers_instruments" + has_and_belongs_to_many :subjects, :class_name => "JamRuby::Subject", :join_table => "teachers_subjects" + has_and_belongs_to_many :languages, :class_name => "JamRuby::Language", :join_table => "teachers_languages" + has_many :teacher_experiences, :class_name => "JamRuby::TeacherExperience" + belongs_to :user, :class_name => 'JamRuby::User' + + validates :user, :presence => true + + class << self + def save_teacher(user, params) + teacher = build_teacher(user, params) + teacher.save + teacher + end + + def build_teacher(user, params) + id = params[:id] + + # ensure person creating this Teacher is a Musician + unless user && user.musician? + raise JamPermissionError, "must be a musician" + end + + teacher = (id.blank?) ? Teacher.new : Teacher.find(id) + teacher.user = user + teacher.website = params[:website] if params.key?(:website) + teacher.biography = params[:biography] if params.key?(:biography) + teacher.introductory_video = params[:introductory_video] if params.key?(:introductory_video) + teacher + end + end + end +end diff --git a/ruby/lib/jam_ruby/models/teacher_experience.rb b/ruby/lib/jam_ruby/models/teacher_experience.rb new file mode 100644 index 000000000..181a62c08 --- /dev/null +++ b/ruby/lib/jam_ruby/models/teacher_experience.rb @@ -0,0 +1,7 @@ +module JamRuby + class TeacherExperience < ActiveRecord::Base + include HtmlSanitize + html_sanitize strict: [:name, :organization] + belongs_to :teacher, :class_name => "JamRuby::Teacher" + end +end diff --git a/ruby/spec/jam_ruby/models/teacher_spec.rb b/ruby/spec/jam_ruby/models/teacher_spec.rb new file mode 100644 index 000000000..f6eca828a --- /dev/null +++ b/ruby/spec/jam_ruby/models/teacher_spec.rb @@ -0,0 +1,37 @@ +require 'spec_helper' + +describe Teacher do + + let(:user) { FactoryGirl.create(:user) } + let(:user2) { FactoryGirl.create(:user) } + let(:fan) { FactoryGirl.create(:fan) } + BIO = "Once a man learned a guitar." + describe "can create" do + it "a simple teacher" do + teacher = Teacher.new + teacher.user = user; + teacher.biography = BIO + teacher.introductory_video = "youtube.com?xyz" + teacher.save.should be_true + t = Teacher.find(teacher.id) + t.biography.should == BIO + t.introductory_video.should == "youtube.com?xyz" + end + end + + describe "using save_teacher can create" do + it "a simple teacher" do + teacher = Teacher.save_teacher( + user, + biography: BIO, + introductory_video: "youtube.com?xyz" + ) + + teacher.should_not be_nil + teacher.id.should_not be_nil + t = Teacher.find(teacher.id) + t.biography.should == BIO + t.introductory_video.should == "youtube.com?xyz" + end + end +end From 98110f68bcc91365033defad82962ba99fa109d5 Mon Sep 17 00:00:00 2001 From: Steven Miers Date: Mon, 27 Jul 2015 17:00:39 -0500 Subject: [PATCH 014/591] VRFS-3359 : Save and test other segments of teacher profile. --- db/up/profile_teacher.sql | 20 ++-- ruby/lib/jam_ruby/models/teacher.rb | 45 ++++++++ ruby/lib/jam_ruby/models/user.rb | 3 +- ruby/spec/factories.rb | 10 ++ ruby/spec/jam_ruby/models/teacher_spec.rb | 123 +++++++++++++++++++++- 5 files changed, 188 insertions(+), 13 deletions(-) diff --git a/db/up/profile_teacher.sql b/db/up/profile_teacher.sql index b8e0134a3..da6e12a5e 100644 --- a/db/up/profile_teacher.sql +++ b/db/up/profile_teacher.sql @@ -6,13 +6,13 @@ CREATE TABLE teachers ( years_playing SMALLINT NOT NULL DEFAULT 0, teaches_age_lower SMALLINT NOT NULL DEFAULT 0, teaches_age_upper SMALLINT NOT NULL DEFAULT 0, - website VARCHAR(1024) NULL, - biography VARCHAR(4096) NULL, teaches_beginner BOOLEAN NOT NULL DEFAULT FALSE, teaches_intermediate BOOLEAN NOT NULL DEFAULT FALSE, teaches_advanced BOOLEAN NOT NULL DEFAULT FALSE, - price_per_lesson BOOLEAN NOT NULL DEFAULT FALSE, - price_per_month BOOLEAN NOT NULL DEFAULT FALSE, + website VARCHAR(1024) NULL, + biography VARCHAR(4096) NULL, + prices_per_lesson BOOLEAN NOT NULL DEFAULT FALSE, + prices_per_month BOOLEAN NOT NULL DEFAULT FALSE, lesson_duration_30 BOOLEAN NOT NULL DEFAULT FALSE, lesson_duration_45 BOOLEAN NOT NULL DEFAULT FALSE, lesson_duration_60 BOOLEAN NOT NULL DEFAULT FALSE, @@ -20,11 +20,11 @@ CREATE TABLE teachers ( lesson_duration_120 BOOLEAN NOT NULL DEFAULT FALSE, price_per_lesson_cents INT NULL, price_per_month_cents INT NULL, - lesson_duration_30_cents INT NULL, - lesson_duration_45_cents INT NULL, - lesson_duration_60_cents INT NULL, - lesson_duration_90_cents INT NULL, - lesson_duration_120_cents INT NULL, + price_duration_30_cents INT NULL, + price_duration_45_cents INT NULL, + price_duration_60_cents INT NULL, + price_duration_90_cents INT NULL, + price_duration_120_cents INT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); @@ -66,7 +66,7 @@ CREATE TABLE teachers_subjects( teacher_id VARCHAR(64) REFERENCES teachers(id) ON DELETE CASCADE, subject_id VARCHAR(64) REFERENCES subjects(id) ON DELETE CASCADE ); -CREATE TABLE teacher_languages( +CREATE TABLE teachers_languages( teacher_id VARCHAR(64) REFERENCES teachers(id) ON DELETE CASCADE, language_id VARCHAR(64) REFERENCES languages(id) ON DELETE CASCADE ); diff --git a/ruby/lib/jam_ruby/models/teacher.rb b/ruby/lib/jam_ruby/models/teacher.rb index 431635cbd..a4e42a73b 100644 --- a/ruby/lib/jam_ruby/models/teacher.rb +++ b/ruby/lib/jam_ruby/models/teacher.rb @@ -31,6 +31,51 @@ module JamRuby teacher.website = params[:website] if params.key?(:website) teacher.biography = params[:biography] if params.key?(:biography) teacher.introductory_video = params[:introductory_video] if params.key?(:introductory_video) + + teacher.introductory_video = params[:introductory_video] if params.key?(:introductory_video) + teacher.years_teaching = params[:years_teaching] if params.key?(:years_teaching) + teacher.years_playing = params[:years_playing] if params.key?(:years_playing) + teacher.teaches_age_lower = params[:teaches_age_lower] if params.key?(:teaches_age_lower) + teacher.teaches_age_upper = params[:teaches_age_upper] if params.key?(:teaches_age_upper) + teacher.website = params[:website] if params.key?(:website) + teacher.biography = params[:biography] if params.key?(:biography) + teacher.teaches_beginner = params[:teaches_beginner] if params.key?(:teaches_beginner) + teacher.teaches_intermediate = params[:teaches_intermediate] if params.key?(:teaches_intermediate) + teacher.teaches_advanced = params[:teaches_advanced] if params.key?(:teaches_advanced) + teacher.prices_per_lesson = params[:prices_per_lesson] if params.key?(:prices_per_lesson) + teacher.prices_per_month = params[:prices_per_month] if params.key?(:prices_per_month) + teacher.lesson_duration_30 = params[:lesson_duration_30] if params.key?(:lesson_duration_30) + teacher.lesson_duration_45 = params[:lesson_duration_45] if params.key?(:lesson_duration_45) + teacher.lesson_duration_60 = params[:lesson_duration_60] if params.key?(:lesson_duration_60) + teacher.lesson_duration_90 = params[:lesson_duration_90] if params.key?(:lesson_duration_90) + teacher.lesson_duration_120 = params[:lesson_duration_120] if params.key?(:lesson_duration_120) + teacher.price_per_lesson_cents = params[:price_per_lesson_cents] if params.key?(:price_per_lesson_cents) + teacher.price_per_month_cents = params[:price_per_month_cents] if params.key?(:price_per_month_cents) + teacher.price_duration_30_cents = params[:price_duration_30_cents] if params.key?(:price_duration_30_cents) + teacher.price_duration_45_cents = params[:price_duration_45_cents] if params.key?(:price_duration_45_cents) + teacher.price_duration_60_cents = params[:price_duration_60_cents] if params.key?(:price_duration_60_cents) + teacher.price_duration_90_cents = params[:price_duration_90_cents] if params.key?(:price_duration_90_cents) + teacher.price_duration_120_cents = params[:price_duration_120_cents] if params.key?(:price_duration_120_cents) + + # Many-to-many relations: + teacher.genres = params[:genres].collect{|genre_id|Genre.find(genre_id)} if params[:genres].present? + teacher.instruments = params[:instruments].collect{|instrument_id|Instrument.find(instrument_id)} if params[:instruments].present? + teacher.subjects = params[:subjects].collect{|subject_id|Subject.find(subject_id)} if params[:subjects].present? + teacher.languages = params[:languages].collect{|language_id|Language.find(language_id)} if params[:languages].present? + + # Experience: + if params[:experience].present? + teacher.teacher_experiences = params[:experience].collect do |exp| + TeacherExperience.new( + name: exp[:name], + experience_type: exp[:experience_type], + organization: exp[:organization], + start_year: exp[:start_year], + end_year: exp[:end_year] + ) + end + end + teacher end end diff --git a/ruby/lib/jam_ruby/models/user.rb b/ruby/lib/jam_ruby/models/user.rb index 217c73339..a7ae1b124 100644 --- a/ruby/lib/jam_ruby/models/user.rb +++ b/ruby/lib/jam_ruby/models/user.rb @@ -62,7 +62,8 @@ module JamRuby # bands has_many :band_musicians, :class_name => "JamRuby::BandMusician" has_many :bands, :through => :band_musicians, :class_name => "JamRuby::Band" - + has_one :teacher, :class_name => "JamRuby::Teacher" + # genres has_many :genre_players, as: :player, class_name: "JamRuby::GenrePlayer", dependent: :destroy has_many :genres, through: :genre_players, class_name: "JamRuby::Genre" diff --git a/ruby/spec/factories.rb b/ruby/spec/factories.rb index 90cd48c44..626b906fb 100644 --- a/ruby/spec/factories.rb +++ b/ruby/spec/factories.rb @@ -223,6 +223,16 @@ FactoryGirl.define do description { |n| "Genre #{n}" } end + factory :language, :class => JamRuby::Language do + name { |n| "Language #{n}" } + description { |n| "Language #{n}" } + end + + factory :subject, :class => JamRuby::Subject do + name { |n| "Subject #{n}" } + description { |n| "Subject #{n}" } + end + factory :join_request, :class => JamRuby::JoinRequest do text 'let me in to the session!' end diff --git a/ruby/spec/jam_ruby/models/teacher_spec.rb b/ruby/spec/jam_ruby/models/teacher_spec.rb index f6eca828a..822c6826d 100644 --- a/ruby/spec/jam_ruby/models/teacher_spec.rb +++ b/ruby/spec/jam_ruby/models/teacher_spec.rb @@ -5,6 +5,15 @@ describe Teacher do let(:user) { FactoryGirl.create(:user) } let(:user2) { FactoryGirl.create(:user) } let(:fan) { FactoryGirl.create(:fan) } + let(:genre1) { FactoryGirl.create(:genre) } + let(:genre2) { FactoryGirl.create(:genre) } + let(:subject1) { FactoryGirl.create(:subject) } + let(:subject2) { FactoryGirl.create(:subject) } + let(:language1) { FactoryGirl.create(:language) } + let(:language2) { FactoryGirl.create(:language) } + let(:instrument1) { FactoryGirl.create(:instrument, :description => 'a great instrument')} + let(:instrument2) { FactoryGirl.create(:instrument, :description => 'an ok instrument')} + BIO = "Once a man learned a guitar." describe "can create" do it "a simple teacher" do @@ -17,14 +26,28 @@ describe Teacher do t.biography.should == BIO t.introductory_video.should == "youtube.com?xyz" end + + + it "a simple teacher" do + teacher = user.build_teacher + teacher.instruments << instrument1 + teacher.instruments << instrument2 + teacher.save.should be_true + puts teacher.errors.messages.inspect + t = Teacher.find(teacher.id) + t.instruments.should have(2).items + end + end describe "using save_teacher can create" do - it "a simple teacher" do + it "introduction" do teacher = Teacher.save_teacher( user, biography: BIO, - introductory_video: "youtube.com?xyz" + introductory_video: "youtube.com?xyz", + years_teaching: 21, + years_playing: 12 ) teacher.should_not be_nil @@ -32,6 +55,102 @@ describe Teacher do t = Teacher.find(teacher.id) t.biography.should == BIO t.introductory_video.should == "youtube.com?xyz" + t.years_teaching.should == 21 + t.years_playing.should == 12 end + + it "basics" do + teacher = Teacher.save_teacher( + user, + instruments: [instrument1, instrument2], + subjects: [subject1, subject2], + genres: [genre1, genre2], + languages: [language1, language2], + teaches_age_lower: 10, + teaches_age_upper: 20, + teaches_beginner: true, + teaches_intermediate: false, + teaches_advanced: true + ) + + t = Teacher.find(teacher.id) + + # Instruments + t.instruments.should have(2).items + + # Genres + t.genres.should have(2).items + + # Subjects + t.subjects.should have(2).items + + # Languages + t.languages.should have(2).items + + t.teaches_age_lower.should == 10 + t.teaches_age_upper.should == 20 + t.teaches_beginner.should be_true + t.teaches_intermediate.should be_false + t.teaches_advanced.should be_true + + end + + it "experience" do + experience = [{ + experience_type: "teaching", + name: "Professor", + organization: "SHSU", + start_year: 1994, + end_year: 2004 + } + ] + + teacher = Teacher.save_teacher(user, experience: experience) + teacher.should_not be_nil + t = Teacher.find(teacher.id) + t.should_not be_nil + + t.teacher_experiences.should have(1).items + end + + it "lesson pricing" do + teacher = Teacher.save_teacher( + user, + prices_per_lesson: true, + prices_per_month: true, + lesson_duration_30: true, + lesson_duration_45: true, + lesson_duration_60: true, + lesson_duration_90: true, + lesson_duration_120: true, + price_per_lesson_cents: 3000, + price_per_month_cents: 3000, + price_duration_30_cents: 3000, + price_duration_45_cents: 3000, + price_duration_60_cents: 3000, + price_duration_90_cents: 3000, + price_duration_120_cents: 3000 + ) + + teacher.should_not be_nil + teacher.id.should_not be_nil + t = Teacher.find(teacher.id) + t.prices_per_lesson.should be_true + t.prices_per_month.should be_true + t.lesson_duration_30.should be_true + t.lesson_duration_45.should be_true + t.lesson_duration_60.should be_true + t.lesson_duration_90.should be_true + t.lesson_duration_120.should be_true + t.price_per_lesson_cents.should == 3000 + t.price_per_month_cents.should == 3000 + t.price_duration_30_cents.should == 3000 + t.price_duration_45_cents.should == 3000 + t.price_duration_60_cents.should == 3000 + t.price_duration_90_cents.should == 3000 + t.price_duration_120_cents.should == 3000 + end + + end end From c89d53790439daf354d585c7d3f1fca658aa1786 Mon Sep 17 00:00:00 2001 From: Steven Miers Date: Tue, 28 Jul 2015 14:00:14 -0500 Subject: [PATCH 015/591] VRFS-3359 : Teacher profile, staged validations and specs. --- ruby/lib/jam_ruby/models/teacher.rb | 30 ++++++++ ruby/spec/jam_ruby/models/teacher_spec.rb | 91 ++++++++++++++++++++++- 2 files changed, 117 insertions(+), 4 deletions(-) diff --git a/ruby/lib/jam_ruby/models/teacher.rb b/ruby/lib/jam_ruby/models/teacher.rb index a4e42a73b..0f6447411 100644 --- a/ruby/lib/jam_ruby/models/teacher.rb +++ b/ruby/lib/jam_ruby/models/teacher.rb @@ -2,6 +2,7 @@ module JamRuby class Teacher < ActiveRecord::Base include HtmlSanitize html_sanitize strict: [:biography, :website] + attr_accessor :validate_introduction, :validate_basics, :validate_pricing has_and_belongs_to_many :genres, :class_name => "JamRuby::Genre", :join_table => "teachers_genres" has_and_belongs_to_many :instruments, :class_name => "JamRuby::Instrument", :join_table => "teachers_instruments" has_and_belongs_to_many :subjects, :class_name => "JamRuby::Subject", :join_table => "teachers_subjects" @@ -10,6 +11,17 @@ module JamRuby belongs_to :user, :class_name => 'JamRuby::User' validates :user, :presence => true + validates :biography, length: {minimum: 5, maximum: 4096}, :if => :validate_introduction + validates :years_teaching, :presence => true, :if => :validate_introduction + validates :years_playing, :presence => true, :if => :validate_introduction + + validates :instruments, :length => { minimum:1, message:"At least one instrument or subject is required"}, if: :validate_basics, unless: ->(teacher){teacher.subjects.length>0} + validates :subjects, :length => { minimum:1, message:"At least one instrument or subject is required"}, if: :validate_basics, unless: ->(teacher){teacher.instruments.length>0} + validates :genres, :length => { minimum:1, message:"At least one genre is required"}, if: :validate_basics + validates :languages, :length => { minimum:1, message:"At least one language is required"}, if: :validate_basics + + validate :offer_pricing, :if => :validate_pricing + validate :offer_duration, :if => :validate_pricing class << self def save_teacher(user, params) @@ -76,8 +88,26 @@ module JamRuby end end + # How to validate: + teacher.validate_introduction = !!params[:validate_introduction] + teacher.validate_basics = !!params[:validate_basics] + teacher.validate_pricing = !!params[:validate_pricing] + teacher end end + + def offer_pricing + unless prices_per_lesson.present? || prices_per_month.present? + errors.add(:offer_pricing, "Must choose to price per lesson or per month") + end + end + + def offer_duration + unless lesson_duration_30.present? || lesson_duration_45.present? || lesson_duration_60.present? || lesson_duration_90.present? || lesson_duration_120.present? + errors.add(:offer_duration, "Must offer at least one duration") + end + end + end end diff --git a/ruby/spec/jam_ruby/models/teacher_spec.rb b/ruby/spec/jam_ruby/models/teacher_spec.rb index 822c6826d..8bd27800e 100644 --- a/ruby/spec/jam_ruby/models/teacher_spec.rb +++ b/ruby/spec/jam_ruby/models/teacher_spec.rb @@ -49,8 +49,8 @@ describe Teacher do years_teaching: 21, years_playing: 12 ) - teacher.should_not be_nil + teacher.errors.should be_empty teacher.id.should_not be_nil t = Teacher.find(teacher.id) t.biography.should == BIO @@ -73,6 +73,9 @@ describe Teacher do teaches_advanced: true ) + teacher.should_not be_nil + teacher.errors.should be_empty + t = Teacher.find(teacher.id) # Instruments @@ -107,6 +110,8 @@ describe Teacher do teacher = Teacher.save_teacher(user, experience: experience) teacher.should_not be_nil + teacher.errors.should be_empty + t = Teacher.find(teacher.id) t.should_not be_nil @@ -134,6 +139,8 @@ describe Teacher do teacher.should_not be_nil teacher.id.should_not be_nil + teacher.errors.should be_empty + t = Teacher.find(teacher.id) t.prices_per_lesson.should be_true t.prices_per_month.should be_true @@ -150,7 +157,83 @@ describe Teacher do t.price_duration_90_cents.should == 3000 t.price_duration_120_cents.should == 3000 end - - end -end + + describe "validates" do + it "introduction" do + teacher = Teacher.save_teacher( + user, + years_teaching: 21, + validate_introduction: true + ) + + teacher.should_not be_nil + teacher.id.should be_nil + teacher.errors.should_not be_empty + + teacher.errors.should have_key(:biography) + end + + it "basics" do + teacher = Teacher.save_teacher( + user, + # instruments: [instrument1, instrument2], + # subjects: [subject1, subject2], + # genres: [genre1, genre2], + # languages: [language1, language2], + validate_basics: true, + teaches_age_lower: 10, + teaches_beginner: true, + teaches_intermediate: false, + teaches_advanced: true + ) + + puts "basic: #{teacher.errors.inspect}" + teacher.should_not be_nil + teacher.id.should be_nil + teacher.errors.should have_key(:instruments) + teacher.errors.should have_key(:subjects) + teacher.errors.should have_key(:genres) + teacher.errors.should have_key(:languages) + end + + it "pricing" do + teacher = Teacher.save_teacher( + user, + prices_per_lesson: false, + prices_per_month: false, + lesson_duration_30: false, + lesson_duration_45: false, + lesson_duration_60: false, + lesson_duration_90: false, + lesson_duration_120: false, + price_per_lesson_cents: 3000, + price_per_month_cents: 3000, + #price_duration_30_cents: 3000, + price_duration_45_cents: 3000, + #price_duration_60_cents: 3000, + #price_duration_90_cents: 3000, + price_duration_120_cents: 3000, + validate_pricing:true + ) + + teacher.should_not be_nil + teacher.id.should be_nil + teacher.errors.should_not be_empty + teacher.errors.should have_key(:offer_pricing) + teacher.errors.should have_key(:offer_duration) + + teacher = Teacher.save_teacher( + user, + prices_per_month: true, + lesson_duration_45: true, + validate_pricing:true + ) + + teacher.should_not be_nil + teacher.id.should_not be_nil + teacher.errors.should be_empty + + end # pricing + end # validates +end # spec From 968722bf8042de36a9185876ae316a930764ec56 Mon Sep 17 00:00:00 2001 From: Steven Miers Date: Sat, 8 Aug 2015 15:00:16 -0500 Subject: [PATCH 016/591] VRFS-3359 : Routes and controller API, with test. --- .../controllers/api_teachers_controller.rb | 45 ++++ web/config/routes.rb | 7 + .../api_teachers_controller_spec.rb | 198 ++++++++++++++++++ 3 files changed, 250 insertions(+) create mode 100644 web/app/controllers/api_teachers_controller.rb create mode 100644 web/spec/controllers/api_teachers_controller_spec.rb diff --git a/web/app/controllers/api_teachers_controller.rb b/web/app/controllers/api_teachers_controller.rb new file mode 100644 index 000000000..04bc3f4be --- /dev/null +++ b/web/app/controllers/api_teachers_controller.rb @@ -0,0 +1,45 @@ +class ApiTeachersController < ApiController + + before_filter :api_signed_in_user, :except => [:index, :show] + before_filter :auth_teacher, :only => [:update, :delete] + + respond_to :json + + def index + @teachers = Teacher.paginate(page: params[:page]) + end + + def show + @teacher = Teacher.find(params[:id]) + respond_with_model(@teacher) + end + + def delete + @teacher.try(:destroy) + respond_with @teacher, responder => ApiResponder + end + + def create + @teacher = Teacher.save_teacher(current_user, params) + respond_with_model(@teacher, new: true, location: lambda { return api_teacher_detail_url(@teacher.id) }) + end + + def update + @teacher = Teacher.save_teacher(current_user, params) + respond_with_model(@teacher) + end + +private + def auth_teacher + if current_user.admin + @teacher = Teacher.find(params[:id]) + else + @teacher = Teacher.where("user_id=? AND id=?", current_user.id, params[:id]).first + end + + unless @teacher + Rails.logger.info("Could not find teacher #{params[:id]} for #{current_user}") + raise JamPermissionError, ValidationMessages::PERMISSION_VALIDATION_ERROR + end + end +end diff --git a/web/config/routes.rb b/web/config/routes.rb index 2281f6937..e0c2544c7 100644 --- a/web/config/routes.rb +++ b/web/config/routes.rb @@ -430,6 +430,13 @@ SampleApp::Application.routes.draw do match '/bands/:id' => 'api_bands#update', :via => :post match '/bands/:id' => 'api_bands#delete', :via => :delete + # teachers + match '/teachers' => 'api_teachers#index', :via => :get + match '/teachers/:id' => 'api_teachers#show', :via => :get, :as => 'api_teacher_detail' + match '/teachers' => 'api_teachers#create', :via => :post + match '/teachers/:id' => 'api_teachers#update', :via => :post + match '/teachers/:id' => 'api_teachers#delete', :via => :delete + # photo match '/bands/:id/photo' => 'api_bands#update_photo', :via => :post match '/bands/:id/photo' => 'api_bands#delete_photo', :via => :delete diff --git a/web/spec/controllers/api_teachers_controller_spec.rb b/web/spec/controllers/api_teachers_controller_spec.rb new file mode 100644 index 000000000..62d5c88c6 --- /dev/null +++ b/web/spec/controllers/api_teachers_controller_spec.rb @@ -0,0 +1,198 @@ +require 'spec_helper' + +describe ApiTeachersController do + render_views + BIO = "Once a man learned a guitar." + + let(:user) { FactoryGirl.create(:user) } + let(:genre1) { FactoryGirl.create(:genre) } + let(:genre2) { FactoryGirl.create(:genre) } + let(:subject1) { FactoryGirl.create(:subject) } + let(:subject2) { FactoryGirl.create(:subject) } + let(:language1) { FactoryGirl.create(:language) } + let(:language2) { FactoryGirl.create(:language) } + let(:instrument1) { FactoryGirl.create(:instrument, :description => 'a great instrument')} + let(:instrument2) { FactoryGirl.create(:instrument, :description => 'an ok instrument')} + + before(:each) do + controller.current_user = user + end + + after(:all) do + User.destroy_all + Teacher.destroy_all + end + + describe "creates" do + it "simple" do + post :create, biography: BIO, format: 'json' + response.should be_success + t = Teacher.find_by_user_id(user) + t.should_not be_nil + + t.biography.should == BIO + end + + + it "with instruments" do + post :create, biography: BIO, instruments: [instrument1, instrument2], format: 'json' + response.should be_success + t = Teacher.find_by_user_id(user) + t.biography.should == BIO + t.instruments.should have(2).items + end + + it "with child records" do + params = { + subjects: [subject1, subject2], + genres: [genre1, genre2], + languages: [language1, language2], + teaches_age_lower: 10, + teaches_age_upper: 20, + teaches_beginner: true, + teaches_intermediate: false, + teaches_advanced: true, + format: 'json' + } + + post :create, params + + response.should be_success + t = Teacher.find_by_user_id(user) + + # Genres + t.genres.should have(2).items + + # Subjects + t.subjects.should have(2).items + + # Languages + t.languages.should have(2).items + + t.teaches_age_lower.should == 10 + t.teaches_age_upper.should == 20 + t.teaches_beginner.should be_true + t.teaches_intermediate.should be_false + t.teaches_advanced.should be_true + end + end + + describe "to existing" do + before :each do + @teacher = Teacher.save_teacher( + user, + years_teaching: 21, + biography: BIO + ) + @teacher.should_not be_nil + @teacher.id.should_not be_nil + end + + it "deletes" do + delete :delete, {:format => 'json', id: @teacher.id} + response.should be_success + t = Teacher.find_by_user_id(user) + t.should be_nil + end + + it "with child records" do + params = { + id: @teacher.id, + subjects: [subject1, subject2], + genres: [genre1, genre2], + languages: [language1, language2], + teaches_age_lower: 10, + teaches_age_upper: 20, + teaches_beginner: true, + teaches_intermediate: false, + teaches_advanced: true, + format: 'json' + } + + post :update, params + + response.should be_success + t = Teacher.find_by_user_id(user) + + # Genres + t.genres.should have(2).items + + # Subjects + t.subjects.should have(2).items + + # Languages + t.languages.should have(2).items + + t.teaches_age_lower.should == 10 + t.teaches_age_upper.should == 20 + t.teaches_beginner.should be_true + t.teaches_intermediate.should be_false + t.teaches_advanced.should be_true + end + end + + describe "validates" do + it "introduction" do + post :create, validate_introduction: true, format: 'json' + response.should_not be_success + response.status.should eq(422) + json = JSON.parse(response.body) + json['errors'].should have_key('biography') + + end + + it "basics" do + post :create, validate_basics: true, format: 'json' + response.should_not be_success + response.status.should eq(422) + json = JSON.parse(response.body) + json['errors'].should have_key('instruments') + json['errors'].should have_key('subjects') + json['errors'].should have_key('genres') + json['errors'].should have_key('languages') + + post :create, instruments: [instrument1, instrument2], validate_basics: true, format: 'json' + response.should_not be_success + response.status.should eq(422) + json = JSON.parse(response.body) + json['errors'].should_not have_key('subjects') + json['errors'].should_not have_key('instruments') + json['errors'].should have_key('genres') + json['errors'].should have_key('languages') + end + + it "pricing" do + params = { + prices_per_lesson: false, + prices_per_month: false, + lesson_duration_30: false, + lesson_duration_45: false, + lesson_duration_60: false, + lesson_duration_90: false, + lesson_duration_120: false, + price_per_lesson_cents: 3000, + price_per_month_cents: 3000, + price_duration_45_cents: 3000, + price_duration_120_cents: 3000, + validate_pricing: true, + format: 'json' + } + post :create, params + response.should_not be_success + response.status.should eq(422) + json = JSON.parse(response.body) + json['errors'].should have_key('offer_pricing') + json['errors'].should have_key('offer_duration') + + # Add lesson duration and resubmit. We should only get one error now: + params[:lesson_duration_45] = true + post :create, params + response.should_not be_success + response.status.should eq(422) + json = JSON.parse(response.body) + json['errors'].should have_key('offer_pricing') + json['errors'].should_not have_key('offer_duration') + #puts "JSON.pretty_generate(json): #{JSON.pretty_generate(json)}" + end + end +end From d76899be5440c26b97180a8b55536d25fcc81993 Mon Sep 17 00:00:00 2001 From: Steven Miers Date: Sat, 8 Aug 2015 15:00:44 -0500 Subject: [PATCH 017/591] VRFS-3359 : Teacher profile REST functions. --- web/app/assets/javascripts/jam_rest.js | 57 +++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/web/app/assets/javascripts/jam_rest.js b/web/app/assets/javascripts/jam_rest.js index 0f1445290..22ca5db52 100644 --- a/web/app/assets/javascripts/jam_rest.js +++ b/web/app/assets/javascripts/jam_rest.js @@ -476,6 +476,58 @@ }); } + + function getTeacher(options) { + // var url = '/api/teacher/detail' + + // if(options && _.size(options) > 0) { + // console.log("WTF"); + // url += "?" + $.param(options) + // } + + // console.log("THE URL", url) + return $.ajax({ + type: "GET", + dataType: "json", + url: '/api/teachers/detail?'+ $.param(options), + contentType: 'application/json', + processData: false + }); + } + + function deleteTeacher(teacherId) { + var url = "/api/teachers/" + teacherId; + return $.ajax({ + type: "DELETE", + dataType: "json", + url: url, + contentType: 'application/json', + processData:false + }); + } + + function updateTeacher(teacher) { + console.log("Updating teacher", teacher) + var id = teacher && teacher["id"] + var url + if (id != null && typeof(id) != 'undefined') { + url = '/api/teachers/' + teacher.id + } else { + url = '/api/teachers' + } + + var deferred = $.ajax({ + type: "POST", + dataType: "json", + url: url, + contentType: 'application/json', + processData: false, + data: JSON.stringify(teacher) + }) + + return deferred + } + function getSession(id) { var url = "/api/sessions/" + id; return $.ajax({ @@ -989,7 +1041,7 @@ type: 'GET', dataType: "json", url: "/api/feeds?" + $.param(options), - processData:false + processData:false }) } @@ -1945,6 +1997,9 @@ this.getBandPhotoFilepickerPolicy = getBandPhotoFilepickerPolicy; this.getBand = getBand; this.validateBand = validateBand; + this.getTeacher = getTeacher; + this.updateTeacher = updateTeacher; + this.deleteTeacher = deleteTeacher; this.updateFavorite = updateFavorite; this.createBandInvitation = createBandInvitation; this.updateBandInvitation = updateBandInvitation; From 176f2b9e2fa83bed8e2e7b69a7352eb9bb3b76d3 Mon Sep 17 00:00:00 2001 From: Steven Miers Date: Sat, 8 Aug 2015 15:01:22 -0500 Subject: [PATCH 018/591] VRFS-3359 : Initial react components. --- .../SessionMediaTracks.js.jsx.coffee | 7 +- .../SessionMetronome.js.jsx.coffee | 1 + .../TeacherProfile.js.jsx.coffee | 99 +++++++++++++++++++ .../TeacherSetupBasics.js.jsx.coffee | 85 ++++++++++++++++ .../TeacherSetupNav.js.jsx.coffee | 25 +++++ .../actions/TeacherActions.js.coffee | 8 ++ .../mixins/TeacherMixin.js.coffee | 14 +++ .../stores/TeacherStore.js.coffee | 59 +++++++++++ 8 files changed, 294 insertions(+), 4 deletions(-) create mode 100644 web/app/assets/javascripts/react-components/TeacherProfile.js.jsx.coffee create mode 100644 web/app/assets/javascripts/react-components/TeacherSetupBasics.js.jsx.coffee create mode 100644 web/app/assets/javascripts/react-components/TeacherSetupNav.js.jsx.coffee create mode 100644 web/app/assets/javascripts/react-components/actions/TeacherActions.js.coffee create mode 100644 web/app/assets/javascripts/react-components/mixins/TeacherMixin.js.coffee create mode 100644 web/app/assets/javascripts/react-components/stores/TeacherStore.js.coffee diff --git a/web/app/assets/javascripts/react-components/SessionMediaTracks.js.jsx.coffee b/web/app/assets/javascripts/react-components/SessionMediaTracks.js.jsx.coffee index f72068f29..d4d50a8ae 100644 --- a/web/app/assets/javascripts/react-components/SessionMediaTracks.js.jsx.coffee +++ b/web/app/assets/javascripts/react-components/SessionMediaTracks.js.jsx.coffee @@ -263,10 +263,9 @@ ChannelGroupIds = context.JK.ChannelGroupIds # All the JamTracks mediaTracks.push(``) - # this is not ready yet until VRFS-3363 is done - #if @state.metronome? - # @state.metronome.mode = MIX_MODES.PERSONAL - # mediaTracks.push(``) + if @state.metronome? + @state.metronome.mode = MIX_MODES.PERSONAL + mediaTracks.push(``) for jamTrack in @state.jamTracks jamTrack.mode = MIX_MODES.PERSONAL diff --git a/web/app/assets/javascripts/react-components/SessionMetronome.js.jsx.coffee b/web/app/assets/javascripts/react-components/SessionMetronome.js.jsx.coffee index d1231642d..aa7654c7e 100644 --- a/web/app/assets/javascripts/react-components/SessionMetronome.js.jsx.coffee +++ b/web/app/assets/javascripts/react-components/SessionMetronome.js.jsx.coffee @@ -33,6 +33,7 @@ MIX_MODES = context.JK.MIX_MODES "session-track" : true "metronome" : true "no-mixer" : @props.mode == MIX_MODES.MASTER # show it as disabled if in master mode + "in-jam-track" : @props.location == 'jam-track' }) pan = if mixers.mixer? then mixers.mixer?.pan else 0 diff --git a/web/app/assets/javascripts/react-components/TeacherProfile.js.jsx.coffee b/web/app/assets/javascripts/react-components/TeacherProfile.js.jsx.coffee new file mode 100644 index 000000000..3576b27a5 --- /dev/null +++ b/web/app/assets/javascripts/react-components/TeacherProfile.js.jsx.coffee @@ -0,0 +1,99 @@ +context = window +teacherActions = window.JK.Actions.Teacher +logger = context.JK.logger +rest = window.JK.Rest() + +@TeacherProfile = React.createClass({ + mixins: [ + @TeacherMixin, + Reflux.listenTo(@AppStore,"onAppInit"), + Reflux.listenTo(TeacherStore, "onTeacherStateChanged") + ] + + setTeacherError: () -> + + + getInitialState: () -> + {} + + beforeShow: (data) -> + logger.debug("TeacherProfile beforeShow", data, data.d) + + + if data? && data.d? + @teacherId = data.d + teacherActions.load.trigger({teacher_id: @teacherId}) + else + teacherActions.load.trigger({}) + + onTeacherStateChanged: (changes) -> + $root = jQuery(this.getDOMNode()) + logger.debug("onTeacherStateChanged", changes, changes.errors?, changes.errors) + $(".error-text", $root).remove() + $(".input-error", $root).removeClass("input-error") + if changes.errors? + for k,v of changes.errors + logger.debug("error", k, v) + teacherField = $root.find(".teacher-field[name='#{k}']") + teacherField.append("
#{v.join()}
") + $("input", teacherField).addClass("input-error") + #$(".error-text", teacherField).show() + + else + teacher = changes.teacher + logger.debug("@teacher", teacher) + this.setState({ + biography: teacher.biography, + introductory_video: teacher.introductory_video, + years_teaching: teacher.years_teaching, + years_playing: teacher.years_playing, + validate_introduction: true + }) + + + + captureFormState: (changes) -> + $root = jQuery(this.getDOMNode()) + this.setState({ + biography: $root.find(".teacher-biography").val(), + introductory_video: $root.find(".teacher-introductory-video").val(), + years_teaching: $root.find(".years-teaching-experience").val(), + years_playing: $root.find(".years-playing-experience").val() + }); + logger.debug("capturedFormState", this.state, changes) + + handleSave: (e) -> + logger.debug("HANDLESAVE: ", this.state, this, e) + teacherActions.change.trigger(this.state) + + render: () -> + logger.debug("RENDERING", this.props, this.state) + `
+
+
+ + "; + support.noCloneChecked = !!div.cloneNode( true ).lastChild.defaultValue; + })(); + var strundefined = typeof undefined; + + + + support.focusinBubbles = "onfocusin" in window; + + + var + rkeyEvent = /^key/, + rmouseEvent = /^(?:mouse|pointer|contextmenu)|click/, + rfocusMorph = /^(?:focusinfocus|focusoutblur)$/, + rtypenamespace = /^([^.]*)(?:\.(.+)|)$/; + + function returnTrue() { + return true; + } + + function returnFalse() { + return false; + } + + function safeActiveElement() { + try { + return document.activeElement; + } catch ( err ) { } + } + + /* + * Helper functions for managing events -- not part of the public interface. + * Props to Dean Edwards' addEvent library for many of the ideas. + */ + jQuery.event = { + + global: {}, + + add: function( elem, types, handler, data, selector ) { + + var handleObjIn, eventHandle, tmp, + events, t, handleObj, + special, handlers, type, namespaces, origType, + elemData = data_priv.get( elem ); + + // Don't attach events to noData or text/comment nodes (but allow plain objects) + if ( !elemData ) { + return; + } + + // Caller can pass in an object of custom data in lieu of the handler + if ( handler.handler ) { + handleObjIn = handler; + handler = handleObjIn.handler; + selector = handleObjIn.selector; + } + + // Make sure that the handler has a unique ID, used to find/remove it later + if ( !handler.guid ) { + handler.guid = jQuery.guid++; + } + + // Init the element's event structure and main handler, if this is the first + if ( !(events = elemData.events) ) { + events = elemData.events = {}; + } + if ( !(eventHandle = elemData.handle) ) { + eventHandle = elemData.handle = function( e ) { + // Discard the second event of a jQuery.event.trigger() and + // when an event is called after a page has unloaded + return typeof jQuery !== strundefined && jQuery.event.triggered !== e.type ? + jQuery.event.dispatch.apply( elem, arguments ) : undefined; + }; + } + + // Handle multiple events separated by a space + types = ( types || "" ).match( rnotwhite ) || [ "" ]; + t = types.length; + while ( t-- ) { + tmp = rtypenamespace.exec( types[t] ) || []; + type = origType = tmp[1]; + namespaces = ( tmp[2] || "" ).split( "." ).sort(); + + // There *must* be a type, no attaching namespace-only handlers + if ( !type ) { + continue; + } + + // If event changes its type, use the special event handlers for the changed type + special = jQuery.event.special[ type ] || {}; + + // If selector defined, determine special event api type, otherwise given type + type = ( selector ? special.delegateType : special.bindType ) || type; + + // Update special based on newly reset type + special = jQuery.event.special[ type ] || {}; + + // handleObj is passed to all event handlers + handleObj = jQuery.extend({ + type: type, + origType: origType, + data: data, + handler: handler, + guid: handler.guid, + selector: selector, + needsContext: selector && jQuery.expr.match.needsContext.test( selector ), + namespace: namespaces.join(".") + }, handleObjIn ); + + // Init the event handler queue if we're the first + if ( !(handlers = events[ type ]) ) { + handlers = events[ type ] = []; + handlers.delegateCount = 0; + + // Only use addEventListener if the special events handler returns false + if ( !special.setup || special.setup.call( elem, data, namespaces, eventHandle ) === false ) { + if ( elem.addEventListener ) { + elem.addEventListener( type, eventHandle, false ); + } + } + } + + if ( special.add ) { + special.add.call( elem, handleObj ); + + if ( !handleObj.handler.guid ) { + handleObj.handler.guid = handler.guid; + } + } + + // Add to the element's handler list, delegates in front + if ( selector ) { + handlers.splice( handlers.delegateCount++, 0, handleObj ); + } else { + handlers.push( handleObj ); + } + + // Keep track of which events have ever been used, for event optimization + jQuery.event.global[ type ] = true; + } + + }, + + // Detach an event or set of events from an element + remove: function( elem, types, handler, selector, mappedTypes ) { + + var j, origCount, tmp, + events, t, handleObj, + special, handlers, type, namespaces, origType, + elemData = data_priv.hasData( elem ) && data_priv.get( elem ); + + if ( !elemData || !(events = elemData.events) ) { + return; + } + + // Once for each type.namespace in types; type may be omitted + types = ( types || "" ).match( rnotwhite ) || [ "" ]; + t = types.length; + while ( t-- ) { + tmp = rtypenamespace.exec( types[t] ) || []; + type = origType = tmp[1]; + namespaces = ( tmp[2] || "" ).split( "." ).sort(); + + // Unbind all events (on this namespace, if provided) for the element + if ( !type ) { + for ( type in events ) { + jQuery.event.remove( elem, type + types[ t ], handler, selector, true ); + } + continue; + } + + special = jQuery.event.special[ type ] || {}; + type = ( selector ? special.delegateType : special.bindType ) || type; + handlers = events[ type ] || []; + tmp = tmp[2] && new RegExp( "(^|\\.)" + namespaces.join("\\.(?:.*\\.|)") + "(\\.|$)" ); + + // Remove matching events + origCount = j = handlers.length; + while ( j-- ) { + handleObj = handlers[ j ]; + + if ( ( mappedTypes || origType === handleObj.origType ) && + ( !handler || handler.guid === handleObj.guid ) && + ( !tmp || tmp.test( handleObj.namespace ) ) && + ( !selector || selector === handleObj.selector || selector === "**" && handleObj.selector ) ) { + handlers.splice( j, 1 ); + + if ( handleObj.selector ) { + handlers.delegateCount--; + } + if ( special.remove ) { + special.remove.call( elem, handleObj ); + } + } + } + + // Remove generic event handler if we removed something and no more handlers exist + // (avoids potential for endless recursion during removal of special event handlers) + if ( origCount && !handlers.length ) { + if ( !special.teardown || special.teardown.call( elem, namespaces, elemData.handle ) === false ) { + jQuery.removeEvent( elem, type, elemData.handle ); + } + + delete events[ type ]; + } + } + + // Remove the expando if it's no longer used + if ( jQuery.isEmptyObject( events ) ) { + delete elemData.handle; + data_priv.remove( elem, "events" ); + } + }, + + trigger: function( event, data, elem, onlyHandlers ) { + + var i, cur, tmp, bubbleType, ontype, handle, special, + eventPath = [ elem || document ], + type = hasOwn.call( event, "type" ) ? event.type : event, + namespaces = hasOwn.call( event, "namespace" ) ? event.namespace.split(".") : []; + + cur = tmp = elem = elem || document; + + // Don't do events on text and comment nodes + if ( elem.nodeType === 3 || elem.nodeType === 8 ) { + return; + } + + // focus/blur morphs to focusin/out; ensure we're not firing them right now + if ( rfocusMorph.test( type + jQuery.event.triggered ) ) { + return; + } + + if ( type.indexOf(".") >= 0 ) { + // Namespaced trigger; create a regexp to match event type in handle() + namespaces = type.split("."); + type = namespaces.shift(); + namespaces.sort(); + } + ontype = type.indexOf(":") < 0 && "on" + type; + + // Caller can pass in a jQuery.Event object, Object, or just an event type string + event = event[ jQuery.expando ] ? + event : + new jQuery.Event( type, typeof event === "object" && event ); + + // Trigger bitmask: & 1 for native handlers; & 2 for jQuery (always true) + event.isTrigger = onlyHandlers ? 2 : 3; + event.namespace = namespaces.join("."); + event.namespace_re = event.namespace ? + new RegExp( "(^|\\.)" + namespaces.join("\\.(?:.*\\.|)") + "(\\.|$)" ) : + null; + + // Clean up the event in case it is being reused + event.result = undefined; + if ( !event.target ) { + event.target = elem; + } + + // Clone any incoming data and prepend the event, creating the handler arg list + data = data == null ? + [ event ] : + jQuery.makeArray( data, [ event ] ); + + // Allow special events to draw outside the lines + special = jQuery.event.special[ type ] || {}; + if ( !onlyHandlers && special.trigger && special.trigger.apply( elem, data ) === false ) { + return; + } + + // Determine event propagation path in advance, per W3C events spec (#9951) + // Bubble up to document, then to window; watch for a global ownerDocument var (#9724) + if ( !onlyHandlers && !special.noBubble && !jQuery.isWindow( elem ) ) { + + bubbleType = special.delegateType || type; + if ( !rfocusMorph.test( bubbleType + type ) ) { + cur = cur.parentNode; + } + for ( ; cur; cur = cur.parentNode ) { + eventPath.push( cur ); + tmp = cur; + } + + // Only add window if we got to document (e.g., not plain obj or detached DOM) + if ( tmp === (elem.ownerDocument || document) ) { + eventPath.push( tmp.defaultView || tmp.parentWindow || window ); + } + } + + // Fire handlers on the event path + i = 0; + while ( (cur = eventPath[i++]) && !event.isPropagationStopped() ) { + + event.type = i > 1 ? + bubbleType : + special.bindType || type; + + // jQuery handler + handle = ( data_priv.get( cur, "events" ) || {} )[ event.type ] && data_priv.get( cur, "handle" ); + if ( handle ) { + handle.apply( cur, data ); + } + + // Native handler + handle = ontype && cur[ ontype ]; + if ( handle && handle.apply && jQuery.acceptData( cur ) ) { + event.result = handle.apply( cur, data ); + if ( event.result === false ) { + event.preventDefault(); + } + } + } + event.type = type; + + // If nobody prevented the default action, do it now + if ( !onlyHandlers && !event.isDefaultPrevented() ) { + + if ( (!special._default || special._default.apply( eventPath.pop(), data ) === false) && + jQuery.acceptData( elem ) ) { + + // Call a native DOM method on the target with the same name name as the event. + // Don't do default actions on window, that's where global variables be (#6170) + if ( ontype && jQuery.isFunction( elem[ type ] ) && !jQuery.isWindow( elem ) ) { + + // Don't re-trigger an onFOO event when we call its FOO() method + tmp = elem[ ontype ]; + + if ( tmp ) { + elem[ ontype ] = null; + } + + // Prevent re-triggering of the same event, since we already bubbled it above + jQuery.event.triggered = type; + elem[ type ](); + jQuery.event.triggered = undefined; + + if ( tmp ) { + elem[ ontype ] = tmp; + } + } + } + } + + return event.result; + }, + + dispatch: function( event ) { + + // Make a writable jQuery.Event from the native event object + event = jQuery.event.fix( event ); + + var i, j, ret, matched, handleObj, + handlerQueue = [], + args = slice.call( arguments ), + handlers = ( data_priv.get( this, "events" ) || {} )[ event.type ] || [], + special = jQuery.event.special[ event.type ] || {}; + + // Use the fix-ed jQuery.Event rather than the (read-only) native event + args[0] = event; + event.delegateTarget = this; + + // Call the preDispatch hook for the mapped type, and let it bail if desired + if ( special.preDispatch && special.preDispatch.call( this, event ) === false ) { + return; + } + + // Determine handlers + handlerQueue = jQuery.event.handlers.call( this, event, handlers ); + + // Run delegates first; they may want to stop propagation beneath us + i = 0; + while ( (matched = handlerQueue[ i++ ]) && !event.isPropagationStopped() ) { + event.currentTarget = matched.elem; + + j = 0; + while ( (handleObj = matched.handlers[ j++ ]) && !event.isImmediatePropagationStopped() ) { + + // Triggered event must either 1) have no namespace, or 2) have namespace(s) + // a subset or equal to those in the bound event (both can have no namespace). + if ( !event.namespace_re || event.namespace_re.test( handleObj.namespace ) ) { + + event.handleObj = handleObj; + event.data = handleObj.data; + + ret = ( (jQuery.event.special[ handleObj.origType ] || {}).handle || handleObj.handler ) + .apply( matched.elem, args ); + + if ( ret !== undefined ) { + if ( (event.result = ret) === false ) { + event.preventDefault(); + event.stopPropagation(); + } + } + } + } + } + + // Call the postDispatch hook for the mapped type + if ( special.postDispatch ) { + special.postDispatch.call( this, event ); + } + + return event.result; + }, + + handlers: function( event, handlers ) { + var i, matches, sel, handleObj, + handlerQueue = [], + delegateCount = handlers.delegateCount, + cur = event.target; + + // Find delegate handlers + // Black-hole SVG instance trees (#13180) + // Avoid non-left-click bubbling in Firefox (#3861) + if ( delegateCount && cur.nodeType && (!event.button || event.type !== "click") ) { + + for ( ; cur !== this; cur = cur.parentNode || this ) { + + // Don't process clicks on disabled elements (#6911, #8165, #11382, #11764) + if ( cur.disabled !== true || event.type !== "click" ) { + matches = []; + for ( i = 0; i < delegateCount; i++ ) { + handleObj = handlers[ i ]; + + // Don't conflict with Object.prototype properties (#13203) + sel = handleObj.selector + " "; + + if ( matches[ sel ] === undefined ) { + matches[ sel ] = handleObj.needsContext ? + jQuery( sel, this ).index( cur ) >= 0 : + jQuery.find( sel, this, null, [ cur ] ).length; + } + if ( matches[ sel ] ) { + matches.push( handleObj ); + } + } + if ( matches.length ) { + handlerQueue.push({ elem: cur, handlers: matches }); + } + } + } + } + + // Add the remaining (directly-bound) handlers + if ( delegateCount < handlers.length ) { + handlerQueue.push({ elem: this, handlers: handlers.slice( delegateCount ) }); + } + + return handlerQueue; + }, + + // Includes some event props shared by KeyEvent and MouseEvent + props: "altKey bubbles cancelable ctrlKey currentTarget eventPhase metaKey relatedTarget shiftKey target timeStamp view which".split(" "), + + fixHooks: {}, + + keyHooks: { + props: "char charCode key keyCode".split(" "), + filter: function( event, original ) { + + // Add which for key events + if ( event.which == null ) { + event.which = original.charCode != null ? original.charCode : original.keyCode; + } + + return event; + } + }, + + mouseHooks: { + props: "button buttons clientX clientY offsetX offsetY pageX pageY screenX screenY toElement".split(" "), + filter: function( event, original ) { + var eventDoc, doc, body, + button = original.button; + + // Calculate pageX/Y if missing and clientX/Y available + if ( event.pageX == null && original.clientX != null ) { + eventDoc = event.target.ownerDocument || document; + doc = eventDoc.documentElement; + body = eventDoc.body; + + event.pageX = original.clientX + ( doc && doc.scrollLeft || body && body.scrollLeft || 0 ) - ( doc && doc.clientLeft || body && body.clientLeft || 0 ); + event.pageY = original.clientY + ( doc && doc.scrollTop || body && body.scrollTop || 0 ) - ( doc && doc.clientTop || body && body.clientTop || 0 ); + } + + // Add which for click: 1 === left; 2 === middle; 3 === right + // Note: button is not normalized, so don't use it + if ( !event.which && button !== undefined ) { + event.which = ( button & 1 ? 1 : ( button & 2 ? 3 : ( button & 4 ? 2 : 0 ) ) ); + } + + return event; + } + }, + + fix: function( event ) { + if ( event[ jQuery.expando ] ) { + return event; + } + + // Create a writable copy of the event object and normalize some properties + var i, prop, copy, + type = event.type, + originalEvent = event, + fixHook = this.fixHooks[ type ]; + + if ( !fixHook ) { + this.fixHooks[ type ] = fixHook = + rmouseEvent.test( type ) ? this.mouseHooks : + rkeyEvent.test( type ) ? this.keyHooks : + {}; + } + copy = fixHook.props ? this.props.concat( fixHook.props ) : this.props; + + event = new jQuery.Event( originalEvent ); + + i = copy.length; + while ( i-- ) { + prop = copy[ i ]; + event[ prop ] = originalEvent[ prop ]; + } + + // Support: Cordova 2.5 (WebKit) (#13255) + // All events should have a target; Cordova deviceready doesn't + if ( !event.target ) { + event.target = document; + } + + // Support: Safari 6.0+, Chrome<28 + // Target should not be a text node (#504, #13143) + if ( event.target.nodeType === 3 ) { + event.target = event.target.parentNode; + } + + return fixHook.filter ? fixHook.filter( event, originalEvent ) : event; + }, + + special: { + load: { + // Prevent triggered image.load events from bubbling to window.load + noBubble: true + }, + focus: { + // Fire native event if possible so blur/focus sequence is correct + trigger: function() { + if ( this !== safeActiveElement() && this.focus ) { + this.focus(); + return false; + } + }, + delegateType: "focusin" + }, + blur: { + trigger: function() { + if ( this === safeActiveElement() && this.blur ) { + this.blur(); + return false; + } + }, + delegateType: "focusout" + }, + click: { + // For checkbox, fire native event so checked state will be right + trigger: function() { + if ( this.type === "checkbox" && this.click && jQuery.nodeName( this, "input" ) ) { + this.click(); + return false; + } + }, + + // For cross-browser consistency, don't fire native .click() on links + _default: function( event ) { + return jQuery.nodeName( event.target, "a" ); + } + }, + + beforeunload: { + postDispatch: function( event ) { + + // Support: Firefox 20+ + // Firefox doesn't alert if the returnValue field is not set. + if ( event.result !== undefined && event.originalEvent ) { + event.originalEvent.returnValue = event.result; + } + } + } + }, + + simulate: function( type, elem, event, bubble ) { + // Piggyback on a donor event to simulate a different one. + // Fake originalEvent to avoid donor's stopPropagation, but if the + // simulated event prevents default then we do the same on the donor. + var e = jQuery.extend( + new jQuery.Event(), + event, + { + type: type, + isSimulated: true, + originalEvent: {} + } + ); + if ( bubble ) { + jQuery.event.trigger( e, null, elem ); + } else { + jQuery.event.dispatch.call( elem, e ); + } + if ( e.isDefaultPrevented() ) { + event.preventDefault(); + } + } + }; + + jQuery.removeEvent = function( elem, type, handle ) { + if ( elem.removeEventListener ) { + elem.removeEventListener( type, handle, false ); + } + }; + + jQuery.Event = function( src, props ) { + // Allow instantiation without the 'new' keyword + if ( !(this instanceof jQuery.Event) ) { + return new jQuery.Event( src, props ); + } + + // Event object + if ( src && src.type ) { + this.originalEvent = src; + this.type = src.type; + + // Events bubbling up the document may have been marked as prevented + // by a handler lower down the tree; reflect the correct value. + this.isDefaultPrevented = src.defaultPrevented || + src.defaultPrevented === undefined && + // Support: Android<4.0 + src.returnValue === false ? + returnTrue : + returnFalse; + + // Event type + } else { + this.type = src; + } + + // Put explicitly provided properties onto the event object + if ( props ) { + jQuery.extend( this, props ); + } + + // Create a timestamp if incoming event doesn't have one + this.timeStamp = src && src.timeStamp || jQuery.now(); + + // Mark it as fixed + this[ jQuery.expando ] = true; + }; + +// jQuery.Event is based on DOM3 Events as specified by the ECMAScript Language Binding +// http://www.w3.org/TR/2003/WD-DOM-Level-3-Events-20030331/ecma-script-binding.html + jQuery.Event.prototype = { + isDefaultPrevented: returnFalse, + isPropagationStopped: returnFalse, + isImmediatePropagationStopped: returnFalse, + + preventDefault: function() { + var e = this.originalEvent; + + this.isDefaultPrevented = returnTrue; + + if ( e && e.preventDefault ) { + e.preventDefault(); + } + }, + stopPropagation: function() { + var e = this.originalEvent; + + this.isPropagationStopped = returnTrue; + + if ( e && e.stopPropagation ) { + e.stopPropagation(); + } + }, + stopImmediatePropagation: function() { + var e = this.originalEvent; + + this.isImmediatePropagationStopped = returnTrue; + + if ( e && e.stopImmediatePropagation ) { + e.stopImmediatePropagation(); + } + + this.stopPropagation(); + } + }; + +// Create mouseenter/leave events using mouseover/out and event-time checks +// Support: Chrome 15+ + jQuery.each({ + mouseenter: "mouseover", + mouseleave: "mouseout", + pointerenter: "pointerover", + pointerleave: "pointerout" + }, function( orig, fix ) { + jQuery.event.special[ orig ] = { + delegateType: fix, + bindType: fix, + + handle: function( event ) { + var ret, + target = this, + related = event.relatedTarget, + handleObj = event.handleObj; + + // For mousenter/leave call the handler if related is outside the target. + // NB: No relatedTarget if the mouse left/entered the browser window + if ( !related || (related !== target && !jQuery.contains( target, related )) ) { + event.type = handleObj.origType; + ret = handleObj.handler.apply( this, arguments ); + event.type = fix; + } + return ret; + } + }; + }); + +// Support: Firefox, Chrome, Safari +// Create "bubbling" focus and blur events + if ( !support.focusinBubbles ) { + jQuery.each({ focus: "focusin", blur: "focusout" }, function( orig, fix ) { + + // Attach a single capturing handler on the document while someone wants focusin/focusout + var handler = function( event ) { + jQuery.event.simulate( fix, event.target, jQuery.event.fix( event ), true ); + }; + + jQuery.event.special[ fix ] = { + setup: function() { + var doc = this.ownerDocument || this, + attaches = data_priv.access( doc, fix ); + + if ( !attaches ) { + doc.addEventListener( orig, handler, true ); + } + data_priv.access( doc, fix, ( attaches || 0 ) + 1 ); + }, + teardown: function() { + var doc = this.ownerDocument || this, + attaches = data_priv.access( doc, fix ) - 1; + + if ( !attaches ) { + doc.removeEventListener( orig, handler, true ); + data_priv.remove( doc, fix ); + + } else { + data_priv.access( doc, fix, attaches ); + } + } + }; + }); + } + + jQuery.fn.extend({ + + on: function( types, selector, data, fn, /*INTERNAL*/ one ) { + var origFn, type; + + // Types can be a map of types/handlers + if ( typeof types === "object" ) { + // ( types-Object, selector, data ) + if ( typeof selector !== "string" ) { + // ( types-Object, data ) + data = data || selector; + selector = undefined; + } + for ( type in types ) { + this.on( type, selector, data, types[ type ], one ); + } + return this; + } + + if ( data == null && fn == null ) { + // ( types, fn ) + fn = selector; + data = selector = undefined; + } else if ( fn == null ) { + if ( typeof selector === "string" ) { + // ( types, selector, fn ) + fn = data; + data = undefined; + } else { + // ( types, data, fn ) + fn = data; + data = selector; + selector = undefined; + } + } + if ( fn === false ) { + fn = returnFalse; + } else if ( !fn ) { + return this; + } + + if ( one === 1 ) { + origFn = fn; + fn = function( event ) { + // Can use an empty set, since event contains the info + jQuery().off( event ); + return origFn.apply( this, arguments ); + }; + // Use same guid so caller can remove using origFn + fn.guid = origFn.guid || ( origFn.guid = jQuery.guid++ ); + } + return this.each( function() { + jQuery.event.add( this, types, fn, data, selector ); + }); + }, + one: function( types, selector, data, fn ) { + return this.on( types, selector, data, fn, 1 ); + }, + off: function( types, selector, fn ) { + var handleObj, type; + if ( types && types.preventDefault && types.handleObj ) { + // ( event ) dispatched jQuery.Event + handleObj = types.handleObj; + jQuery( types.delegateTarget ).off( + handleObj.namespace ? handleObj.origType + "." + handleObj.namespace : handleObj.origType, + handleObj.selector, + handleObj.handler + ); + return this; + } + if ( typeof types === "object" ) { + // ( types-object [, selector] ) + for ( type in types ) { + this.off( type, selector, types[ type ] ); + } + return this; + } + if ( selector === false || typeof selector === "function" ) { + // ( types [, fn] ) + fn = selector; + selector = undefined; + } + if ( fn === false ) { + fn = returnFalse; + } + return this.each(function() { + jQuery.event.remove( this, types, fn, selector ); + }); + }, + + trigger: function( type, data ) { + return this.each(function() { + jQuery.event.trigger( type, data, this ); + }); + }, + triggerHandler: function( type, data ) { + var elem = this[0]; + if ( elem ) { + return jQuery.event.trigger( type, data, elem, true ); + } + } + }); + + + var + rxhtmlTag = /<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi, + rtagName = /<([\w:]+)/, + rhtml = /<|&#?\w+;/, + rnoInnerhtml = /<(?:script|style|link)/i, + // checked="checked" or checked + rchecked = /checked\s*(?:[^=]|=\s*.checked.)/i, + rscriptType = /^$|\/(?:java|ecma)script/i, + rscriptTypeMasked = /^true\/(.*)/, + rcleanScript = /^\s*\s*$/g, + + // We have to close these tags to support XHTML (#13200) + wrapMap = { + + // Support: IE9 + option: [ 1, "" ], + + thead: [ 1, "", "
" ], + col: [ 2, "", "
" ], + tr: [ 2, "", "
" ], + td: [ 3, "", "
" ], + + _default: [ 0, "", "" ] + }; + +// Support: IE9 + wrapMap.optgroup = wrapMap.option; + + wrapMap.tbody = wrapMap.tfoot = wrapMap.colgroup = wrapMap.caption = wrapMap.thead; + wrapMap.th = wrapMap.td; + +// Support: 1.x compatibility +// Manipulating tables requires a tbody + function manipulationTarget( elem, content ) { + return jQuery.nodeName( elem, "table" ) && + jQuery.nodeName( content.nodeType !== 11 ? content : content.firstChild, "tr" ) ? + + elem.getElementsByTagName("tbody")[0] || + elem.appendChild( elem.ownerDocument.createElement("tbody") ) : + elem; + } + +// Replace/restore the type attribute of script elements for safe DOM manipulation + function disableScript( elem ) { + elem.type = (elem.getAttribute("type") !== null) + "/" + elem.type; + return elem; + } + function restoreScript( elem ) { + var match = rscriptTypeMasked.exec( elem.type ); + + if ( match ) { + elem.type = match[ 1 ]; + } else { + elem.removeAttribute("type"); + } + + return elem; + } + +// Mark scripts as having already been evaluated + function setGlobalEval( elems, refElements ) { + var i = 0, + l = elems.length; + + for ( ; i < l; i++ ) { + data_priv.set( + elems[ i ], "globalEval", !refElements || data_priv.get( refElements[ i ], "globalEval" ) + ); + } + } + + function cloneCopyEvent( src, dest ) { + var i, l, type, pdataOld, pdataCur, udataOld, udataCur, events; + + if ( dest.nodeType !== 1 ) { + return; + } + + // 1. Copy private data: events, handlers, etc. + if ( data_priv.hasData( src ) ) { + pdataOld = data_priv.access( src ); + pdataCur = data_priv.set( dest, pdataOld ); + events = pdataOld.events; + + if ( events ) { + delete pdataCur.handle; + pdataCur.events = {}; + + for ( type in events ) { + for ( i = 0, l = events[ type ].length; i < l; i++ ) { + jQuery.event.add( dest, type, events[ type ][ i ] ); + } + } + } + } + + // 2. Copy user data + if ( data_user.hasData( src ) ) { + udataOld = data_user.access( src ); + udataCur = jQuery.extend( {}, udataOld ); + + data_user.set( dest, udataCur ); + } + } + + function getAll( context, tag ) { + var ret = context.getElementsByTagName ? context.getElementsByTagName( tag || "*" ) : + context.querySelectorAll ? context.querySelectorAll( tag || "*" ) : + []; + + return tag === undefined || tag && jQuery.nodeName( context, tag ) ? + jQuery.merge( [ context ], ret ) : + ret; + } + +// Fix IE bugs, see support tests + function fixInput( src, dest ) { + var nodeName = dest.nodeName.toLowerCase(); + + // Fails to persist the checked state of a cloned checkbox or radio button. + if ( nodeName === "input" && rcheckableType.test( src.type ) ) { + dest.checked = src.checked; + + // Fails to return the selected option to the default selected state when cloning options + } else if ( nodeName === "input" || nodeName === "textarea" ) { + dest.defaultValue = src.defaultValue; + } + } + + jQuery.extend({ + clone: function( elem, dataAndEvents, deepDataAndEvents ) { + var i, l, srcElements, destElements, + clone = elem.cloneNode( true ), + inPage = jQuery.contains( elem.ownerDocument, elem ); + + // Fix IE cloning issues + if ( !support.noCloneChecked && ( elem.nodeType === 1 || elem.nodeType === 11 ) && + !jQuery.isXMLDoc( elem ) ) { + + // We eschew Sizzle here for performance reasons: http://jsperf.com/getall-vs-sizzle/2 + destElements = getAll( clone ); + srcElements = getAll( elem ); + + for ( i = 0, l = srcElements.length; i < l; i++ ) { + fixInput( srcElements[ i ], destElements[ i ] ); + } + } + + // Copy the events from the original to the clone + if ( dataAndEvents ) { + if ( deepDataAndEvents ) { + srcElements = srcElements || getAll( elem ); + destElements = destElements || getAll( clone ); + + for ( i = 0, l = srcElements.length; i < l; i++ ) { + cloneCopyEvent( srcElements[ i ], destElements[ i ] ); + } + } else { + cloneCopyEvent( elem, clone ); + } + } + + // Preserve script evaluation history + destElements = getAll( clone, "script" ); + if ( destElements.length > 0 ) { + setGlobalEval( destElements, !inPage && getAll( elem, "script" ) ); + } + + // Return the cloned set + return clone; + }, + + buildFragment: function( elems, context, scripts, selection ) { + var elem, tmp, tag, wrap, contains, j, + fragment = context.createDocumentFragment(), + nodes = [], + i = 0, + l = elems.length; + + for ( ; i < l; i++ ) { + elem = elems[ i ]; + + if ( elem || elem === 0 ) { + + // Add nodes directly + if ( jQuery.type( elem ) === "object" ) { + // Support: QtWebKit, PhantomJS + // push.apply(_, arraylike) throws on ancient WebKit + jQuery.merge( nodes, elem.nodeType ? [ elem ] : elem ); + + // Convert non-html into a text node + } else if ( !rhtml.test( elem ) ) { + nodes.push( context.createTextNode( elem ) ); + + // Convert html into DOM nodes + } else { + tmp = tmp || fragment.appendChild( context.createElement("div") ); + + // Deserialize a standard representation + tag = ( rtagName.exec( elem ) || [ "", "" ] )[ 1 ].toLowerCase(); + wrap = wrapMap[ tag ] || wrapMap._default; + tmp.innerHTML = wrap[ 1 ] + elem.replace( rxhtmlTag, "<$1>" ) + wrap[ 2 ]; + + // Descend through wrappers to the right content + j = wrap[ 0 ]; + while ( j-- ) { + tmp = tmp.lastChild; + } + + // Support: QtWebKit, PhantomJS + // push.apply(_, arraylike) throws on ancient WebKit + jQuery.merge( nodes, tmp.childNodes ); + + // Remember the top-level container + tmp = fragment.firstChild; + + // Ensure the created nodes are orphaned (#12392) + tmp.textContent = ""; + } + } + } + + // Remove wrapper from fragment + fragment.textContent = ""; + + i = 0; + while ( (elem = nodes[ i++ ]) ) { + + // #4087 - If origin and destination elements are the same, and this is + // that element, do not do anything + if ( selection && jQuery.inArray( elem, selection ) !== -1 ) { + continue; + } + + contains = jQuery.contains( elem.ownerDocument, elem ); + + // Append to fragment + tmp = getAll( fragment.appendChild( elem ), "script" ); + + // Preserve script evaluation history + if ( contains ) { + setGlobalEval( tmp ); + } + + // Capture executables + if ( scripts ) { + j = 0; + while ( (elem = tmp[ j++ ]) ) { + if ( rscriptType.test( elem.type || "" ) ) { + scripts.push( elem ); + } + } + } + } + + return fragment; + }, + + cleanData: function( elems ) { + var data, elem, type, key, + special = jQuery.event.special, + i = 0; + + for ( ; (elem = elems[ i ]) !== undefined; i++ ) { + if ( jQuery.acceptData( elem ) ) { + key = elem[ data_priv.expando ]; + + if ( key && (data = data_priv.cache[ key ]) ) { + if ( data.events ) { + for ( type in data.events ) { + if ( special[ type ] ) { + jQuery.event.remove( elem, type ); + + // This is a shortcut to avoid jQuery.event.remove's overhead + } else { + jQuery.removeEvent( elem, type, data.handle ); + } + } + } + if ( data_priv.cache[ key ] ) { + // Discard any remaining `private` data + delete data_priv.cache[ key ]; + } + } + } + // Discard any remaining `user` data + delete data_user.cache[ elem[ data_user.expando ] ]; + } + } + }); + + jQuery.fn.extend({ + text: function( value ) { + return access( this, function( value ) { + return value === undefined ? + jQuery.text( this ) : + this.empty().each(function() { + if ( this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9 ) { + this.textContent = value; + } + }); + }, null, value, arguments.length ); + }, + + append: function() { + return this.domManip( arguments, function( elem ) { + if ( this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9 ) { + var target = manipulationTarget( this, elem ); + target.appendChild( elem ); + } + }); + }, + + prepend: function() { + return this.domManip( arguments, function( elem ) { + if ( this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9 ) { + var target = manipulationTarget( this, elem ); + target.insertBefore( elem, target.firstChild ); + } + }); + }, + + before: function() { + return this.domManip( arguments, function( elem ) { + if ( this.parentNode ) { + this.parentNode.insertBefore( elem, this ); + } + }); + }, + + after: function() { + return this.domManip( arguments, function( elem ) { + if ( this.parentNode ) { + this.parentNode.insertBefore( elem, this.nextSibling ); + } + }); + }, + + remove: function( selector, keepData /* Internal Use Only */ ) { + var elem, + elems = selector ? jQuery.filter( selector, this ) : this, + i = 0; + + for ( ; (elem = elems[i]) != null; i++ ) { + if ( !keepData && elem.nodeType === 1 ) { + jQuery.cleanData( getAll( elem ) ); + } + + if ( elem.parentNode ) { + if ( keepData && jQuery.contains( elem.ownerDocument, elem ) ) { + setGlobalEval( getAll( elem, "script" ) ); + } + elem.parentNode.removeChild( elem ); + } + } + + return this; + }, + + empty: function() { + var elem, + i = 0; + + for ( ; (elem = this[i]) != null; i++ ) { + if ( elem.nodeType === 1 ) { + + // Prevent memory leaks + jQuery.cleanData( getAll( elem, false ) ); + + // Remove any remaining nodes + elem.textContent = ""; + } + } + + return this; + }, + + clone: function( dataAndEvents, deepDataAndEvents ) { + dataAndEvents = dataAndEvents == null ? false : dataAndEvents; + deepDataAndEvents = deepDataAndEvents == null ? dataAndEvents : deepDataAndEvents; + + return this.map(function() { + return jQuery.clone( this, dataAndEvents, deepDataAndEvents ); + }); + }, + + html: function( value ) { + return access( this, function( value ) { + var elem = this[ 0 ] || {}, + i = 0, + l = this.length; + + if ( value === undefined && elem.nodeType === 1 ) { + return elem.innerHTML; + } + + // See if we can take a shortcut and just use innerHTML + if ( typeof value === "string" && !rnoInnerhtml.test( value ) && + !wrapMap[ ( rtagName.exec( value ) || [ "", "" ] )[ 1 ].toLowerCase() ] ) { + + value = value.replace( rxhtmlTag, "<$1>" ); + + try { + for ( ; i < l; i++ ) { + elem = this[ i ] || {}; + + // Remove element nodes and prevent memory leaks + if ( elem.nodeType === 1 ) { + jQuery.cleanData( getAll( elem, false ) ); + elem.innerHTML = value; + } + } + + elem = 0; + + // If using innerHTML throws an exception, use the fallback method + } catch( e ) {} + } + + if ( elem ) { + this.empty().append( value ); + } + }, null, value, arguments.length ); + }, + + replaceWith: function() { + var arg = arguments[ 0 ]; + + // Make the changes, replacing each context element with the new content + this.domManip( arguments, function( elem ) { + arg = this.parentNode; + + jQuery.cleanData( getAll( this ) ); + + if ( arg ) { + arg.replaceChild( elem, this ); + } + }); + + // Force removal if there was no new content (e.g., from empty arguments) + return arg && (arg.length || arg.nodeType) ? this : this.remove(); + }, + + detach: function( selector ) { + return this.remove( selector, true ); + }, + + domManip: function( args, callback ) { + + // Flatten any nested arrays + args = concat.apply( [], args ); + + var fragment, first, scripts, hasScripts, node, doc, + i = 0, + l = this.length, + set = this, + iNoClone = l - 1, + value = args[ 0 ], + isFunction = jQuery.isFunction( value ); + + // We can't cloneNode fragments that contain checked, in WebKit + if ( isFunction || + ( l > 1 && typeof value === "string" && + !support.checkClone && rchecked.test( value ) ) ) { + return this.each(function( index ) { + var self = set.eq( index ); + if ( isFunction ) { + args[ 0 ] = value.call( this, index, self.html() ); + } + self.domManip( args, callback ); + }); + } + + if ( l ) { + fragment = jQuery.buildFragment( args, this[ 0 ].ownerDocument, false, this ); + first = fragment.firstChild; + + if ( fragment.childNodes.length === 1 ) { + fragment = first; + } + + if ( first ) { + scripts = jQuery.map( getAll( fragment, "script" ), disableScript ); + hasScripts = scripts.length; + + // Use the original fragment for the last item instead of the first because it can end up + // being emptied incorrectly in certain situations (#8070). + for ( ; i < l; i++ ) { + node = fragment; + + if ( i !== iNoClone ) { + node = jQuery.clone( node, true, true ); + + // Keep references to cloned scripts for later restoration + if ( hasScripts ) { + // Support: QtWebKit + // jQuery.merge because push.apply(_, arraylike) throws + jQuery.merge( scripts, getAll( node, "script" ) ); + } + } + + callback.call( this[ i ], node, i ); + } + + if ( hasScripts ) { + doc = scripts[ scripts.length - 1 ].ownerDocument; + + // Reenable scripts + jQuery.map( scripts, restoreScript ); + + // Evaluate executable scripts on first document insertion + for ( i = 0; i < hasScripts; i++ ) { + node = scripts[ i ]; + if ( rscriptType.test( node.type || "" ) && + !data_priv.access( node, "globalEval" ) && jQuery.contains( doc, node ) ) { + + if ( node.src ) { + // Optional AJAX dependency, but won't run scripts if not present + if ( jQuery._evalUrl ) { + jQuery._evalUrl( node.src ); + } + } else { + jQuery.globalEval( node.textContent.replace( rcleanScript, "" ) ); + } + } + } + } + } + } + + return this; + } + }); + + jQuery.each({ + appendTo: "append", + prependTo: "prepend", + insertBefore: "before", + insertAfter: "after", + replaceAll: "replaceWith" + }, function( name, original ) { + jQuery.fn[ name ] = function( selector ) { + var elems, + ret = [], + insert = jQuery( selector ), + last = insert.length - 1, + i = 0; + + for ( ; i <= last; i++ ) { + elems = i === last ? this : this.clone( true ); + jQuery( insert[ i ] )[ original ]( elems ); + + // Support: QtWebKit + // .get() because push.apply(_, arraylike) throws + push.apply( ret, elems.get() ); + } + + return this.pushStack( ret ); + }; + }); + + + var iframe, + elemdisplay = {}; + + /** + * Retrieve the actual display of a element + * @param {String} name nodeName of the element + * @param {Object} doc Document object + */ +// Called only from within defaultDisplay + function actualDisplay( name, doc ) { + var style, + elem = jQuery( doc.createElement( name ) ).appendTo( doc.body ), + + // getDefaultComputedStyle might be reliably used only on attached element + display = window.getDefaultComputedStyle && ( style = window.getDefaultComputedStyle( elem[ 0 ] ) ) ? + + // Use of this method is a temporary fix (more like optimization) until something better comes along, + // since it was removed from specification and supported only in FF + style.display : jQuery.css( elem[ 0 ], "display" ); + + // We don't have any data stored on the element, + // so use "detach" method as fast way to get rid of the element + elem.detach(); + + return display; + } + + /** + * Try to determine the default display value of an element + * @param {String} nodeName + */ + function defaultDisplay( nodeName ) { + var doc = document, + display = elemdisplay[ nodeName ]; + + if ( !display ) { + display = actualDisplay( nodeName, doc ); + + // If the simple way fails, read from inside an iframe + if ( display === "none" || !display ) { + + // Use the already-created iframe if possible + iframe = (iframe || jQuery( "