# PandaPal ## LTI Configuration A standard Canvas LTI may have a configuration similar to the one below ```ruby # config/initializers/lti.rb PandaPal.lti_options = { title: 'Teacher Reports' } PandaPal.lti_properties = {oauth_compliant: 'true'} # Environments reflect those used by Canvas PandaPal.lti_environments = { domain: ENV['PROD_DOMAIN'], beta_domain: ENV['BETA_DOMAIN'], test_domain: ENV['TEST_DOMAIN'] } PandaPal.lti_custom_params = { custom_canvas_role: '$Canvas.membership.roles' } # :user_navigation, :course_navigation for user context and course context endpoints respectively PandaPal::stage_navigation(:account_navigation, { enabled: true, # url: :account_index, # Optional if using lti_nav text: 'Teacher Reports', visibility: 'admins', }) ``` Configuration data for an installation can be set by creating a `PandaPal::Organization` record. Due to the nature of the data segregation, once created, the name of the organization should not be changed (and will raise a validation error). ### Canvas Installation As of version 5.5.0, LTIs can be installed into Canvas via the console: ```ruby org.install_lti( host: "https://your_lti.herokuapp.com", context: "account/self", # (Optional) Or "account/3", "course/1", etc exists: :error, # (Optional) Action to take if an LTI with the same Key already exists. Options are :error, :replace, :duplicate, :update version: "v1p3", # (Optional, default `v1p3`) LTI Version. Accepts `v1p0` or `v1p3`. dedicated_deployment: false, # (Optional) If true, the Organization will be updated to link to a single deployment rather then to the general LTI Key. (experimental, LTI 1.3 only) ) ``` ### LTI 1.3 Configuration LTI 1.3 has some additional configuration steps required to setup an LTI: 1. If you're running Canvas locally, make sure the `config/redis.yml` and `config/dynamic_settings.yml` files exist in Canvas. 2. Also make sure `config/security.yml` is present and set `development.lti_iss` to `'http://localhost:3000'` (where 3000 is the port you're running Canvas on). 3. In prod, you'll need to generate a RSA Private Key for the LTI to use. You can set the `LTI_PRIVATE_KEY` ENV variable, or manually set `PandaPal.lti_private_key = OpenSSL::PKey::RSA.new(key)`. 4. Make sure you have Redis installed and linked correctly 5. Your PandaPal::Organization's `key` should be `CLIENT_ID/DEPLOYMENT_ID` (which can be found in Canvas). If a Deployment ID is not given, the key should just be `CLIENT_ID`. ### Launch URL property LTI Spec: `The launch_url contains the URL to which the LTI Launch is to be sent. The secure_launch_url is the URL to use if secure http is required. One of either the launch_url or the secure_launch_url must be specified.` Bridge is now validating that this launch URL exists and requires it per the LTI spec. As of PandaPal 3.1.0, you now have 6 options to use this. Use one of these 6 options in `PandaPal.lti_options` hash. 1. Use option `secure_launch_url: 'http://domain.com/path'` when a full static secure domain is needed. 2. Use option `secure_launch_path: '/path'` when you need the secure options and want the host to be dynamic and just need to provide a path. 3. Use option `launch_url: 'http://domain.com/path'` when a full static domain is needed. 4. Use option `launch_path: '/path'` when you don't need the secure options and want the host to be dynamic and just need to provide a path. 5. Leave this property off, and you will get the dynamic host with the root path ('http://appdomain.com/') by default. 6. If you really do not want this property use the option `launch_url: false` for it to be left off. ### Task Scheduling `PandaPal` includes an integration with `sidekiq-scheduler`. You can define tasks on an Organization class Stub like so: ```ruby # /app/models/organization_extension.rb module OrganizationExtension extend ActiveSupport::Concern # Will invoke CanvasSyncStarterWorker.perform_async() according to the cron schedule scheduled_task '0 15 05 * * *', :identifier, worker: CanvasSyncStarterWorker # Will invoke the method 'organization_method' on the Organization scheduled_task '0 15 05 * * *', :organization_method_and_identifier # If you need to invoke the same method on multiple schedules scheduled_task '0 15 05 * * *', :identifier, worker: :organization_method # You can also use a block scheduled_task '0 15 05 * * *', :identifier do # Do Stuff end # You can use a Proc (called in the context of the Organization) to determine the schedule scheduled_task -> { settings[:cron] }, :identifier # You can specify a timezone. If a TZ is not coded and settings[:timezone] is present, it will be appended automatically scheduled_task '0 15 05 * * * America/Denver', :identifier, worker: :organization_method # Setting settings[:task_schedules][:identifier] will override the code cron schedule. Setting it to false will disable the Task # :identifer values _must_ be unique, but can be nil, in which case they will be determined by where (lineno etc) scheduled_task is called end ``` ### Organization Attributes `id`: Primary Key `name`: Name of the organization. Used to on requests to select the tenant `key`: Key field from CanvasLMS `secret`: Secret field from CanvasLMS `canvas_account_id`: ID of the corresponding Canvas account. `settings`: Hash of settings for this Organization `salesforce_id`: ID of this organization in Salesforce XML for an installation can be generated by visiting `/lti/config` in your application. ### Generated API Methods It's common to need to manually trigger logic during UAT. PandaPal 5.6.0+ adds a feature to enable clients to do this themselves. This is done by a deployer accessing the console and creating a `PandaPal::ApiCall`. This can be done like so: ```ruby org.create_api(<<~RUBY, expiration: 30.days.from_now, uses: 10) arg1 = p[:some_param] # `p` is an in-scope variable that is a Hash of the params sent to the triggering request. PandaPal::Organization.current.trigger_canvas_sync() { foo: "bar" } # Will be returned to the client. Can be anything that is JSON serializable. RUBY # OR org.create_api(:symbol_of_method_on_organization, expiration: 30.days.from_now, uses: 10) ``` This will return a URL like such: `/panda_pal/call_logic?some_param=TODO&token=JWT.TOKEN.IS.HERE` that can be either GET'd or POST'd. The URL generator will *attempt* to determine which params the logic accepts and insert them as `param=TODO` in the generated URL. When triggered, the return value of the code will be wrapped and sent to the client: ```json { "status": "ok", "uses_remaining": 9, "result": { "foo": "bar", } } ``` #### Revoking A Call URI may be revoked by deleting the `PandaPal::ApiCall` object. `uses:` and logic are stored in the DB (and can thus be altered), but `expiration:` is stored in the JWT and is thus immutable. ### Routing The following routes should be added to the routes.rb file of the implementing LTI. (substituting `account_navigation` with the other staged navigation routes, if necessary) ```ruby # config/routes.rb mount PandaPal::Engine, at: '/lti' root to: 'panda_pal/lti#launch' # Add Launch Endpoints: lti_nav account_navigation: 'accounts#launch', auto_launch: false # (LTI <1.3 Default) # -- OR -- scope '/organizations/:organization_id' do lti_nav account_navigation: 'accounts#launch_landing', auto_launch: true # (LTI 1.3 Default) lti_nav account_navigation: 'accounts#launch_landing' # Automatically sets auto_launch to true because :organization_id is part of the path # ... end ``` `auto_launch`: Setting to `true` will tell PandaPal to handle all of the launch details and session creation, and then pass off to the defined action. Setting it to `false` indicates that the defined action handles launch validation and setup itself (this has been the legacy approach). Because `auto_launch: false` is most similar to the previous behavior, it is the default for LTI 1.0/1.1 LTIs. For LTI 1.3 LTIs, `auto_launch: true` is the default. If not specified and `:organization_id` is detected in the Route Path, `auto_launch` will be set to `true` ## Implementating data segregation This engine uses Apartment to keep data segregated between installations of the implementing LTI tool. By default, it does this by inspecting the path of the request, and matching URLs containing `orgs` or `organizations`, followed by the numeric ID of the organization (path such as `/organizations/123/accounts`). As such, all URLs should be built on top of a routing scope, such as the example below. ```ruby scope '/organizations/:organization_id' do resources :accounts, only: :index do collection do get 'teachers' => 'accounts#teachers' end end end ``` This can be overriden by creating an initializer, and adding a custom elevator ```ruby Rails.application.config.middleware.delete 'Apartment::Elevators::Generic' Rails.application.config.middleware.use 'Apartment::Elevators::Generic', lambda { |request| if match = request.path.match(/\/(?:orgs|organizations)\/(\d+)/) PandaPal::Organization.find_by(id: match[1]).try(:name) end } ``` It is also possible to switch tenants by implementing `around_action :switch_tenant` in the controller. However, it should be noted that calling `switch_tenant` must be used in an `around_action` hook (or given a block), and is only intended to be used for LTI launches, and not subsequent requests. ### Sharding PandaPal 5.12 added support for multi-DB sharding. If you wish to use this, add a `shard` column to the `PandaPal::Organization` model. The implementation should be fairly transparent and should behave as normal PandaPal/Apartment - the only difference is that tenants can be created on different DB shards. The list of available shards is computed from the Environment variables. On startup, the application looks for variables with the following formats: - `SHARD_DB_XYZ_URL` - `HEROKU_POSTGRESQL_XYZ_URL` (where `XYZ` is the shard identifier) When an `Organization` is created, it is assigned to a random shard (or if no shards were discovered, it is placed in the default database as normal). Alternatively, a specific shard can be specified: `PandaPal::Organization.new(shard: "shard_a")`. An `Organization` can be added to the primary shard with `shard: "default"` The `PandaPal::Organization` record is still created in the `public` schema of the default database, as per usual. ### Rake tasks and jobs **Delayed Job Support has been removed. This allows each project to assess it's need to handle background jobs.** The Apartment Gem makes it so background jobs need to be run within the current tenant. If using sidekiq, there is a gem that does this automatically called `apartment-sidkiq`. If Delayed Jobs, see how it's done in version 1 of PandaPal. For rake tasks, the current tenant will be set to `public`, and thus special handling must be taken when running the task or queueing jobs as part of it. One potential solution is to create a method with switches between tenants, and invoke the job for each individual organization as necessary. ```ruby # config/initializers/global_methods.rb def switch_tenant(tenant, &block) if block_given? Apartment::Tenant.switch(tenant, &block) else Apartment::Tenant.switch! tenant end end ``` ```ruby # lib/tasks/lti_tasks.rake namespace :lti_tasks do desc 'some rake task' task some_rake_task: :environment do PandaPal::Organization.all.each do |org| switch_tenant(org.name) do SomeJob.perform_later end end end end ``` ## Controller helper methods Controllers will automatically have access to a number of methods that can be helpful to implement. A list with descriptions is provided below. * `validate_launch!` - should be used in any tool launch points, and will verify that the launch request received from Canvas is legitimate based on the saved key and secret. * `current_session` - PandaPal provides support to use database persisted sessions, rather than the standard rails variety, as these play better when the tool is launched in an `iframe`. The `session_key` attribute from the returned object needs to be served to the client at some point during the launch and during subsequent requests. The benefit from this is that sessions will exist entirely in the frame used to launch the tool, and will not be visible should the user be accessing the tool in other contexts simultaneously. See section below for more information about this method. * `current_session_data` - Shortcut method to access the `data` hash given by the above method. * `current_organization` - Used to return the organization related to the currently selected tenant. See section below for more information about this method. * `session_changed?` - returns true if the session given by `current_session` has been modified during the request. * `save_session` - Saves the session given by `current_session` to the database. ## Sessions and `current_session` Because of the ever-increasing security around `