canvas_sync 0.12.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/canvas_sync.rb +85 -30
  4. data/lib/canvas_sync/api_syncable.rb +4 -162
  5. data/lib/canvas_sync/class_callback_executor.rb +35 -0
  6. data/lib/canvas_sync/concerns/account/ancestry.rb +60 -0
  7. data/lib/canvas_sync/concerns/api_syncable.rb +189 -0
  8. data/lib/canvas_sync/concerns/legacy_columns.rb +34 -0
  9. data/lib/canvas_sync/generators/templates/migrations/create_group_memberships.rb +18 -0
  10. data/lib/canvas_sync/generators/templates/migrations/create_groups.rb +23 -0
  11. data/lib/canvas_sync/generators/templates/migrations/create_pseudonyms.rb +18 -0
  12. data/lib/canvas_sync/generators/templates/migrations/create_submissions.rb +1 -0
  13. data/lib/canvas_sync/generators/templates/models/account.rb +11 -1
  14. data/lib/canvas_sync/generators/templates/models/admin.rb +2 -1
  15. data/lib/canvas_sync/generators/templates/models/assignment.rb +2 -1
  16. data/lib/canvas_sync/generators/templates/models/assignment_group.rb +2 -1
  17. data/lib/canvas_sync/generators/templates/models/context_module.rb +2 -1
  18. data/lib/canvas_sync/generators/templates/models/context_module_item.rb +2 -1
  19. data/lib/canvas_sync/generators/templates/models/course.rb +4 -2
  20. data/lib/canvas_sync/generators/templates/models/enrollment.rb +2 -1
  21. data/lib/canvas_sync/generators/templates/models/group.rb +19 -0
  22. data/lib/canvas_sync/generators/templates/models/group_membership.rb +17 -0
  23. data/lib/canvas_sync/generators/templates/models/pseudonym.rb +8 -0
  24. data/lib/canvas_sync/generators/templates/models/role.rb +2 -1
  25. data/lib/canvas_sync/generators/templates/models/section.rb +2 -1
  26. data/lib/canvas_sync/generators/templates/models/submission.rb +3 -1
  27. data/lib/canvas_sync/generators/templates/models/term.rb +2 -1
  28. data/lib/canvas_sync/generators/templates/models/user.rb +4 -1
  29. data/lib/canvas_sync/importers/bulk_importer.rb +7 -1
  30. data/lib/canvas_sync/importers/legacy_importer.rb +4 -2
  31. data/lib/canvas_sync/job.rb +3 -1
  32. data/lib/canvas_sync/job_chain.rb +57 -0
  33. data/lib/canvas_sync/jobs/{sync_users_job.rb → sync_accounts_job.rb} +11 -6
  34. data/lib/canvas_sync/jobs/sync_provisioning_report_job.rb +2 -0
  35. data/lib/canvas_sync/processors/model_mappings.yml +81 -0
  36. data/lib/canvas_sync/processors/provisioning_report_processor.rb +28 -0
  37. data/lib/canvas_sync/record.rb +9 -0
  38. data/lib/canvas_sync/version.rb +1 -1
  39. data/spec/canvas_sync/canvas_sync_spec.rb +19 -16
  40. data/spec/canvas_sync/models/accounts_spec.rb +3 -0
  41. data/spec/canvas_sync/models/course_spec.rb +4 -0
  42. data/spec/canvas_sync/models/group_membership_spec.rb +26 -0
  43. data/spec/canvas_sync/models/group_spec.rb +26 -0
  44. data/spec/canvas_sync/models/user_spec.rb +2 -0
  45. data/spec/canvas_sync/processors/provisioning_report_processor_spec.rb +20 -0
  46. data/spec/dummy/app/models/account.rb +8 -1
  47. data/spec/dummy/app/models/admin.rb +2 -1
  48. data/spec/dummy/app/models/assignment.rb +2 -1
  49. data/spec/dummy/app/models/assignment_group.rb +2 -1
  50. data/spec/dummy/app/models/context_module.rb +2 -1
  51. data/spec/dummy/app/models/context_module_item.rb +2 -1
  52. data/spec/dummy/app/models/course.rb +4 -2
  53. data/spec/dummy/app/models/enrollment.rb +2 -1
  54. data/spec/dummy/app/models/group.rb +25 -0
  55. data/spec/dummy/app/models/group_membership.rb +23 -0
  56. data/spec/dummy/app/models/role.rb +2 -1
  57. data/spec/dummy/app/models/section.rb +2 -1
  58. data/spec/dummy/app/models/submission.rb +2 -1
  59. data/spec/dummy/app/models/term.rb +2 -1
  60. data/spec/dummy/app/models/user.rb +3 -1
  61. data/spec/dummy/config/application.rb +12 -1
  62. data/spec/dummy/config/database.yml +11 -11
  63. data/spec/dummy/config/environments/development.rb +3 -3
  64. data/spec/dummy/config/initializers/assets.rb +1 -1
  65. data/spec/dummy/db/migrate/20190702203627_create_submissions.rb +1 -0
  66. data/spec/dummy/db/migrate/20200415171620_create_groups.rb +29 -0
  67. data/spec/dummy/db/migrate/20200416214248_create_group_memberships.rb +24 -0
  68. data/spec/dummy/db/schema.rb +31 -1
  69. data/spec/factories/group_factory.rb +8 -0
  70. data/spec/factories/group_membership_factory.rb +6 -0
  71. data/spec/support/fixtures/reports/group_memberships.csv +3 -0
  72. data/spec/support/fixtures/reports/groups.csv +3 -0
  73. data/spec/support/fixtures/reports/submissions.csv +3 -3
  74. metadata +36 -6
  75. data/spec/canvas_sync/jobs/sync_users_job_spec.rb +0 -15
@@ -8,4 +8,7 @@ RSpec.describe Account, type: :model do
8
8
  it { should validate_uniqueness_of(:canvas_id) }
9
9
  end
10
10
 
11
+ describe 'associations' do
12
+ it { should have_many(:groups) }
13
+ end
11
14
  end
@@ -41,5 +41,9 @@ RSpec.describe Course, type: :model do
41
41
  it do
42
42
  should have_many(:submissions)
43
43
  end
44
+
45
+ it do
46
+ should have_many(:groups)
47
+ end
44
48
  end
45
49
  end
@@ -0,0 +1,26 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe GroupMembership, type: :model do
4
+ let(:subject) {
5
+ FactoryGirl.create(:group_membership, group: FactoryGirl.create(:group), user: FactoryGirl.create(:user))
6
+ }
7
+
8
+ describe 'validations' do
9
+ it { should validate_presence_of(:canvas_id) }
10
+ it { should validate_uniqueness_of(:canvas_id) }
11
+ end
12
+
13
+ describe 'associations' do
14
+ it do
15
+ should belong_to(:group)
16
+ .with_primary_key(:canvas_id)
17
+ .with_foreign_key(:canvas_group_id)
18
+ end
19
+
20
+ it do
21
+ should belong_to(:user)
22
+ .with_primary_key(:canvas_id)
23
+ .with_foreign_key(:canvas_user_id)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,26 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe Group, type: :model do
4
+ let(:subject) { FactoryGirl.create(:group) }
5
+
6
+ describe 'validations' do
7
+ it { should validate_presence_of(:canvas_id) }
8
+ it { should validate_uniqueness_of(:canvas_id) }
9
+ end
10
+
11
+ describe 'associations' do
12
+ it { should have_many(:group_memberships) }
13
+
14
+ it do
15
+ should belong_to(:course)
16
+ .with_primary_key(:canvas_id)
17
+ .with_foreign_key(:canvas_course_id)
18
+ end
19
+
20
+ it do
21
+ should belong_to(:account)
22
+ .with_primary_key(:canvas_id)
23
+ .with_foreign_key(:canvas_account_id)
24
+ end
25
+ end
26
+ end
@@ -9,6 +9,8 @@ RSpec.describe User, type: :model do
9
9
  end
10
10
 
11
11
  describe 'associations' do
12
+ it { should have_many(:group_memberships) }
13
+
12
14
  it do
13
15
  should have_many(:enrollments)
14
16
  .with_primary_key(:canvas_id)
@@ -42,6 +42,26 @@ RSpec.describe CanvasSync::Processors::ProvisioningReportProcessor do
42
42
  expect(cross_listed_section.canvas_nonxlist_course_id).to eq 2
43
43
  end
44
44
 
45
+ it 'processes groups' do
46
+ expect {
47
+ subject.process('spec/support/fixtures/reports/groups.csv', { models: ['groups'] }, 1)
48
+ }.to change { Group.count }.by(2)
49
+ group = Group.find_by_name('Group1')
50
+ expect(group.workflow_state).to eq 'available'
51
+ expect(group.canvas_account_id).to eq 1
52
+ expect(group.canvas_id).to eq 50
53
+ end
54
+
55
+ it 'processes group memberships' do
56
+ expect {
57
+ subject.process('spec/support/fixtures/reports/group_memberships.csv', { models: ['group_membership'] }, 1)
58
+ }.to change { GroupMembership.count }.by(2)
59
+ group = GroupMembership.find_by_canvas_id(50)
60
+ expect(group.workflow_state).to eq 'accepted'
61
+ expect(group.canvas_group_id).to eq 1
62
+ expect(group.canvas_user_id).to eq 10
63
+ end
64
+
45
65
  context 'options[:models] is multiple models' do
46
66
  it 'extracts the ZIP and processes each model' do
47
67
  user_count = User.count
@@ -7,11 +7,18 @@
7
7
 
8
8
 
9
9
  class Account < ApplicationRecord
10
- include CanvasSync::ApiSyncable
10
+ include CanvasSync::Record
11
+ include CanvasSync::Concerns::ApiSyncable
12
+ # include CanvasSync::Concerns::Account::Ancestry # Add support for the ancestry Gem
11
13
 
12
14
  validates :canvas_id, uniqueness: true, presence: true
13
15
 
14
16
  has_many :admins, primary_key: :canvas_id, foreign_key: :canvas_account_id
17
+ belongs_to :canvas_parent, class_name: 'Account', optional: true,
18
+ primary_key: :canvas_id, foreign_key: :canvas_parent_account_id
19
+ has_many :sub_accounts, class_name: 'Account',
20
+ primary_key: :canvas_id, foreign_key: :canvas_parent_account_id
21
+ has_many :groups, primary_key: :canvas_id, foreign_key: :canvas_account_id
15
22
 
16
23
  api_syncable({
17
24
  name: :name,
@@ -7,7 +7,8 @@
7
7
 
8
8
 
9
9
  class Admin < ApplicationRecord
10
- include CanvasSync::ApiSyncable
10
+ include CanvasSync::Record
11
+ include CanvasSync::Concerns::ApiSyncable
11
12
 
12
13
  validates :canvas_id, uniqueness: true, presence: true
13
14
  belongs_to :account, primary_key: :canvas_id, foreign_key: :canvas_account_id, optional: true
@@ -7,7 +7,8 @@
7
7
 
8
8
 
9
9
  class Assignment < ApplicationRecord
10
- include CanvasSync::ApiSyncable
10
+ include CanvasSync::Record
11
+ include CanvasSync::Concerns::ApiSyncable
11
12
 
12
13
  validates :canvas_id, uniqueness: true, presence: true
13
14
  belongs_to :context, polymorphic: true, optional: true, primary_key: :canvas_id, foreign_key: :canvas_context_id, foreign_type: :canvas_context_type
@@ -7,7 +7,8 @@
7
7
 
8
8
 
9
9
  class AssignmentGroup < ApplicationRecord
10
- include CanvasSync::ApiSyncable
10
+ include CanvasSync::Record
11
+ include CanvasSync::Concerns::ApiSyncable
11
12
 
12
13
  validates :canvas_id, uniqueness: true, presence: true
13
14
  belongs_to :course, primary_key: :canvas_id, foreign_key: :canvas_course_id, optional: true
@@ -10,7 +10,8 @@
10
10
  # 1 - Module is a reserved word in Rails and you can't call a model a Module
11
11
  # 2 - Canvas calls them ContextModules
12
12
  class ContextModule < ApplicationRecord
13
- include CanvasSync::ApiSyncable
13
+ include CanvasSync::Record
14
+ include CanvasSync::Concerns::ApiSyncable
14
15
 
15
16
  belongs_to :context, polymorphic: true, optional: true, primary_key: :canvas_id, foreign_key: :canvas_context_id, foreign_type: :canvas_context_type
16
17
  has_many :context_module_items, primary_key: :canvas_id, foreign_key: :canvas_context_module_id
@@ -7,7 +7,8 @@
7
7
 
8
8
 
9
9
  class ContextModuleItem < ApplicationRecord
10
- include CanvasSync::ApiSyncable
10
+ include CanvasSync::Record
11
+ include CanvasSync::Concerns::ApiSyncable
11
12
 
12
13
  belongs_to :context_module, primary_key: :canvas_id, foreign_key: :canvas_context_module_id, optional: true
13
14
  belongs_to :content, polymorphic: true, optional: true, primary_key: :canvas_id, foreign_key: :canvas_content_id, foreign_type: :canvas_content_type
@@ -7,7 +7,8 @@
7
7
 
8
8
 
9
9
  class Course < ApplicationRecord
10
- include CanvasSync::ApiSyncable
10
+ include CanvasSync::Record
11
+ include CanvasSync::Concerns::ApiSyncable
11
12
 
12
13
  validates :canvas_id, uniqueness: true, presence: true
13
14
  belongs_to :term, foreign_key: :canvas_term_id, primary_key: :canvas_id, optional: true
@@ -16,7 +17,8 @@ class Course < ApplicationRecord
16
17
  has_many :assignments, as: :context, primary_key: :canvas_id, foreign_key: :canvas_context_id, foreign_type: :canvas_context_type
17
18
  has_many :submissions, primary_key: :canvas_id, foreign_key: :canvas_course_id
18
19
  has_many :assignment_groups, primary_key: :canvas_id, foreign_key: :canvas_course_id
19
-
20
+ has_many :groups, primary_key: :canvas_id, foreign_key: :canvas_course_id
21
+
20
22
  api_syncable({
21
23
  sis_id: :sis_course_id,
22
24
  course_code: :course_code,
@@ -7,7 +7,8 @@
7
7
 
8
8
 
9
9
  class Enrollment < ApplicationRecord
10
- include CanvasSync::ApiSyncable
10
+ include CanvasSync::Record
11
+ include CanvasSync::Concerns::ApiSyncable
11
12
 
12
13
  validates :canvas_id, uniqueness: true, presence: true
13
14
  belongs_to :user, primary_key: :canvas_id, foreign_key: :canvas_user_id, optional: true
@@ -0,0 +1,25 @@
1
+ # #
2
+ # AUTO GENERATED MODEL
3
+ # This model was auto generated by the CanvasSync Gem.
4
+ # You can customize it as needed, but make sure you test
5
+ # any changes you make to the auto generated methods.
6
+ #
7
+
8
+
9
+ class Group < ApplicationRecord
10
+ include CanvasSync::Record
11
+ include CanvasSync::Concerns::ApiSyncable
12
+
13
+ validates :canvas_id, uniqueness: true, presence: true
14
+ belongs_to :course, primary_key: :canvas_id, foreign_key: :canvas_course_id, optional: true
15
+ belongs_to :account, primary_key: :canvas_id, foreign_key: :canvas_account_id, optional: true
16
+ has_many :group_memberships, primary_key: :canvas_id, foreign_key: :canvas_group_id
17
+
18
+ api_syncable({
19
+ canvas_id: :id,
20
+ name: :name,
21
+ canvas_course_id: :course_id,
22
+ sis_id: :sis_group_id,
23
+ workflow_state: ->(r){ 'available' },
24
+ }, -> (api) { api.group(canvas_id) })
25
+ end
@@ -0,0 +1,23 @@
1
+ # #
2
+ # AUTO GENERATED MODEL
3
+ # This model was auto generated by the CanvasSync Gem.
4
+ # You can customize it as needed, but make sure you test
5
+ # any changes you make to the auto generated methods.
6
+ #
7
+
8
+
9
+ class GroupMembership < ApplicationRecord
10
+ include CanvasSync::Record
11
+ include CanvasSync::Concerns::ApiSyncable
12
+
13
+ validates :canvas_id, uniqueness: true, presence: true
14
+ belongs_to :group, primary_key: :canvas_id, foreign_key: :canvas_group_id
15
+ belongs_to :user, primary_key: :canvas_id, foreign_key: :canvas_user_id
16
+
17
+ api_syncable({
18
+ canvas_id: :id,
19
+ canvas_group_id: :group_id,
20
+ canvas_user_id: :user_id,
21
+ workflow_state: :workflow_state,
22
+ }, -> (api) { api.group_membership(canvas_group_id, canvas_id) })
23
+ end
@@ -7,7 +7,8 @@
7
7
 
8
8
 
9
9
  class Role < ApplicationRecord
10
- include CanvasSync::ApiSyncable
10
+ include CanvasSync::Record
11
+ include CanvasSync::Concerns::ApiSyncable
11
12
 
12
13
  validates :canvas_id, uniqueness: true, presence: true
13
14
  has_many :admins, foreign_key: :canvas_role_id, primary_key: :canvas_id
@@ -7,7 +7,8 @@
7
7
 
8
8
 
9
9
  class Section < ApplicationRecord
10
- include CanvasSync::ApiSyncable
10
+ include CanvasSync::Record
11
+ include CanvasSync::Concerns::ApiSyncable
11
12
 
12
13
  validates :canvas_id, uniqueness: true, presence: true
13
14
  belongs_to :course, primary_key: :canvas_id, foreign_key: :canvas_course_id, optional: true
@@ -7,7 +7,8 @@
7
7
 
8
8
 
9
9
  class Submission < ApplicationRecord
10
- include CanvasSync::ApiSyncable
10
+ include CanvasSync::Record
11
+ include CanvasSync::Concerns::ApiSyncable
11
12
 
12
13
  validates :canvas_id, uniqueness: true, presence: true
13
14
  belongs_to :assignment, primary_key: :canvas_id, foreign_key: :canvas_assignment_id, optional: true
@@ -7,7 +7,8 @@
7
7
 
8
8
 
9
9
  class Term < ApplicationRecord
10
- include CanvasSync::ApiSyncable
10
+ include CanvasSync::Record
11
+ include CanvasSync::Concerns::ApiSyncable
11
12
 
12
13
  validates :canvas_id, uniqueness: true, presence: true
13
14
  has_many :courses, foreign_key: :canvas_term_id, primary_key: :canvas_id
@@ -7,13 +7,15 @@
7
7
 
8
8
 
9
9
  class User < ApplicationRecord
10
- include CanvasSync::ApiSyncable
10
+ include CanvasSync::Record
11
+ include CanvasSync::Concerns::ApiSyncable
11
12
 
12
13
  validates :canvas_id, uniqueness: true, presence: true
13
14
  has_many :enrollments, primary_key: :canvas_id, foreign_key: :canvas_user_id
14
15
  has_many :admins, primary_key: :canvas_id, foreign_key: :canvas_user_id
15
16
  has_many :admin_roles, through: :admins, source: :role
16
17
  has_many :submissions, primary_key: :canvas_id, foreign_key: :canvas_user_id
18
+ has_many :group_memberships, primary_key: :canvas_id, foreign_key: :canvas_user_id
17
19
 
18
20
  api_syncable({
19
21
  sis_id: :sis_user_id,
@@ -1,6 +1,17 @@
1
1
  require File.expand_path('../boot', __FILE__)
2
2
 
3
- require 'rails/all'
3
+ require 'rails'
4
+ require 'active_record/railtie'
5
+ # require 'active_storage/engine'
6
+ require 'action_controller/railtie'
7
+ require 'action_view/railtie'
8
+ require 'action_mailer/railtie'
9
+ require 'active_job/railtie'
10
+ # require 'action_cable/engine'
11
+ # require 'action_mailbox/engine'
12
+ # require 'action_text/engine'
13
+ require 'rails/test_unit/railtie'
14
+ # require 'sprockets/railtie'
4
15
 
5
16
  Bundler.require(*Rails.groups)
6
17
  require "canvas_sync"
@@ -1,25 +1,25 @@
1
- # SQLite version 3.x
2
- # gem install sqlite3
3
- #
4
- # Ensure the SQLite 3 gem is defined in your Gemfile
5
- # gem 'sqlite3'
6
- #
1
+
7
2
  default: &default
8
3
  adapter: postgresql
9
- pool: 5
10
- timeout: 5000
4
+ encoding: unicode
5
+ # For details on connection pooling, see Rails configuration guide
6
+ # http://guides.rubyonrails.org/configuring.html#database-pooling
7
+ pool: <%= ENV.fetch("DB_POOL_SIZE", nil) || (ENV.fetch("RAILS_MAX_THREADS") { 5 }.to_i + 10) %>
8
+ username: <%= ENV.fetch("DB_USERNAME", "") %>
9
+ password: <%= ENV.fetch("DB_PASSWORD", "") %>
10
+ host: <%= ENV.fetch("DB_ADDRESS", "localhost") %>
11
11
 
12
12
  development:
13
13
  <<: *default
14
14
  database: canvas_sync_development
15
15
 
16
- # Warning: The database defined as "test" will be erased and
17
- # re-generated from your development database when you run "rake".
18
- # Do not set this db to the same as development or production.
19
16
  test:
20
17
  <<: *default
21
18
  database: canvas_sync_test
22
19
 
23
20
  production:
24
21
  <<: *default
22
+ host: <%= ENV.fetch('DB_ADDRESS', 'localhost') %>
25
23
  database: canvas_sync_production
24
+ username: <%= ENV.fetch("DB_USERNAME", "canvas_sync_specs_postgres_user") %>
25
+ password: <%= ENV.fetch("DB_PASSWORD", 'canvas_sync_specs_postgres_password') %>
@@ -25,16 +25,16 @@ Rails.application.configure do
25
25
  # Debug mode disables concatenation and preprocessing of assets.
26
26
  # This option may cause significant delays in view rendering with a large
27
27
  # number of complex assets.
28
- config.assets.debug = true
28
+ # config.assets.debug = true
29
29
 
30
30
  # Asset digests allow you to set far-future HTTP expiration dates on all assets,
31
31
  # yet still be able to expire them through the digest params.
32
- config.assets.digest = true
32
+ # config.assets.digest = true
33
33
 
34
34
  # Adds additional error checking when serving assets at runtime.
35
35
  # Checks for improperly declared sprockets dependencies.
36
36
  # Raises helpful error messages.
37
- config.assets.raise_runtime_errors = true
37
+ # config.assets.raise_runtime_errors = true
38
38
 
39
39
  # Raises error for missing translations
40
40
  # config.action_view.raise_on_missing_translations = true
@@ -1,7 +1,7 @@
1
1
  # Be sure to restart your server when you modify this file.
2
2
 
3
3
  # Version of your assets, change this if you want to expire all your assets.
4
- Rails.application.config.assets.version = '1.0'
4
+ # Rails.application.config.assets.version = '1.0'
5
5
 
6
6
  # Add additional assets to the asset load path
7
7
  # Rails.application.config.assets.paths << Emoji.images_path
@@ -14,6 +14,7 @@ class CreateSubmissions < ActiveRecord::Migration[5.1]
14
14
  t.bigint :canvas_assignment_id
15
15
  t.bigint :canvas_user_id
16
16
  t.datetime :submitted_at
17
+ t.datetime :due_at
17
18
  t.datetime :graded_at
18
19
  t.float :score
19
20
  t.float :points_possible
@@ -0,0 +1,29 @@
1
+ # #
2
+ # AUTO GENERATED MIGRATION
3
+ # This migration was auto generated by the CanvasSync Gem.
4
+ # You can add new columns to this table, but removing or
5
+ # re-naming ones created here may break Canvas Syncing.
6
+ #
7
+
8
+
9
+ class CreateGroups < ActiveRecord::Migration[5.1]
10
+ def change
11
+ create_table :groups do |t|
12
+ t.bigint :canvas_id, null: false
13
+ t.string :sis_id
14
+ t.bigint :canvas_group_category_id
15
+ t.string :group_category_sis_id
16
+ t.bigint :canvas_account_id
17
+ t.bigint :canvas_course_id
18
+ t.string :name
19
+ t.string :workflow_state
20
+ t.bigint :context_id
21
+ t.string :context_type
22
+ t.integer :max_membership
23
+
24
+ t.timestamps
25
+ end
26
+
27
+ add_index :groups, :canvas_id, unique: true
28
+ end
29
+ end