1 of 34

LMS App�Code Design Patterns

2 of 34

Feature Flags

3 of 34

Models

4 of 34

Thin Models

  • Standard sqlalchemy models like h, same “thin models” pattern as h has
    • sqlalchemy queries and object creation moved into services instead
    • There are some violations of thin models here -- this is code that just hasn’t been moved into services yet
  • ApplicationInstance is an instance of our app installed in an LMS.
    • It’s a record of the ID and secret, and other metadata, generated by the /welcome page
  • ModuleItemConfiguration is when an assignment’s configuration (the chosen document) is stored in our own DB rather than stored by the LMS
    • This is for “DB-configured” assignments
  • OAuth2Token is our OAuth 2 tokens for the Canvas API
    • Zero-or-one per LTI user
  • LTILaunches is for the stupid /reports page
    • It adds a row to a DB table every time anyone launches our app

5 of 34

Services

6 of 34

Services

  • The pattern is the same as in h:
    • Services wrap up bits of logic and functionality, making them reusable, and keeping that logic out of the views and out of the models, and keeping Pyramid and sqlalchemy out of our logic�
  • ApplicationInstanceGetter retrieves ApplicationInstance’s from the DB
  • CanvasAPIClient makes requests to the Canvas API
  • HypothesisAPIService makes requests to the Hypothesis API�(inconsistent naming here)
  • LaunchVerifier verifies the OAuth 1 sigs on LTI launch requests
    • This probably belongs in authentication or validation, not services
    • (Currently it’s only called by LaunchParamsSchema)

7 of 34

CanvasAPIClient.send_with_refresh_and_retry()

def send_with_refresh_and_retry(self, request, schema, refresh_token):

try:

return self._helper.validated_response(request, schema).parsed_params

except CanvasAPIAccessTokenError:

if not refresh_token:

raise

new_access_token = self.get_refreshed_token(refresh_token)

return self._helper.validated_response(

request, schema, new_access_token

).parsed_params

8 of 34

Services Exceptions

lms/services/exceptions.py:

class ServiceError(Exception):

class LTILaunchVerificationError(ServiceError):

class NoConsumerKey(LTILaunchVerificationError):

class ConsumerKeyError(LTILaunchVerificationError):

class LTIOAuthError(LTILaunchVerificationError):

class ExternalRequestError(ServiceError):

class HAPIError(ExternalRequestError):

...

9 of 34

The LMS App is a SPA

10 of 34

11 of 34

Validation Schemas

12 of 34

marshmallow and webargs

  • All serialization, deserialization / parsing, and validation, is handled by validation schemas in lms/validation/
  • The schemas are implemented using marshmallow, a Python serialization/deserialization/validation library
  • We also use webargs, a marshmallow wrapper that integrates marshmallow into web frameworks nicely, for validating incoming requests
    • (webargs is from the creators of marshmallow)
  • We also use marshmallow schemas to validate responses when we call third-party services using requests (e.g. the Canvas API)
  • marshmallow and webargs are assumed knowledge!

13 of 34

Protecting views with validation schemas

from lms.validation import FooSchema

@view_config(..., schema=FooSchema)

def foo_view(request):

validated_param_1 = request.parsed_params["param_1"]

validated_param_2 = request.parsed_params["param_2"]

14 of 34

Validating responses with validation schemas

import requests

from lms.validation import BarSchema

response = requests.get(...)

try:

parsed_params = BarSchema(response).parse()

except lms.validation.ValidationError as err:

validated_param_1 = parsed_params["param_1"]

validated_param_2 = parsed_params["param_2"]

15 of 34

We also use schemas to serialize things

try:

lti_user_object = LaunchParamsSchema(request).lti_user()

except ValidationError:

>>> BearerTokenSchema(request).lti_user()

LTIUser(user_id=’...’, oauth_consumer_key=’...’, …)

>>> CanvasOAuthCallbackSchema(request).lti_user()

LTIUser(user_id=’...’, oauth_consumer_key=’...’, …)

>>> CanvasOAuthCallbackSchema(request).state_param()

‘yyz...123’

16 of 34

Validation Exceptions

lms/validation/_exceptions.py:

class ValidationError(Exception):

class ExpiredSessionTokenError(ValidationError):

class MissingSessionTokenError(ValidationError):

class InvalidSessionTokenError(ValidationError):

class MissingStateParamError(ValidationError):

class ExpiredStateParamError(ValidationError):

class InvalidStateParamError(ValidationError):

17 of 34

Local Helpers Pattern�(no util dir!)

18 of 34

Local Helpers

  • lms/views/helpers/
    • __init__.py
    • _authentication.py
    • _canvas_files.py
    • _via.py
  • lms/views/predicates/_helpers.py
  • lms/authentication/_helpers.py
  • lms/validation/_helpers/
  • lms/services/helpers/
  • lms/extensions/feature_flags/_helpers.py

19 of 34

Authentication

20 of 34

LTIUser

class LTIUser(NamedTuple):

"""An LTI user."""

user_id: str

"""The user_id LTI launch parameter."""

oauth_consumer_key: str

"""The oauth_consumer_key LTI launch parameter."""

roles: str

"""The user's LTI roles."""

An immutable value object that represents an LTI user -- user_id, roles, etc.

21 of 34

Getting an LTIUser for the current request

from lms.validation import LaunchParamsSchema, BearerTokenSchema, CanvasOAu...

# Derive an LTIUser from the current request’s LTI launch params.

lti_user = LaunchParamsSchema(request).lti_user()

# Derive an LTIUser from the current request’s JWT bearer token.

lti_user = BearerTokenSchema(request).lti_user()

# Derive an LTIUser from the current request’s OAuth 2.0 `state` param

# (used when receiving OAuth 2.0 redirect requests from Canvas)

lti_user = CanvasOAuthCallbackSchema(request).lti_user()

22 of 34

request.lti_user

  • request.lti_user is an LTIUser for the currently authenticated LTI user, or None.
    • Set automatically by lms.authentication._helpers.get_lti_user()

23 of 34

Authentication Policies

  • As well as request.lti_user, we also implement standard Pyramid authentication policies
    • These set request.authenticated_userid and request.effective_principals
    • And enable standard Pyramid permissions and ACLs to work (like h uses)
  • LTIAuthenticationPolicy:
    • Reads request.lti_user
      • (which might have come from the LTI launch params, from a JWT bearer token, or from an OAuth 2 state param)
    • Sets request.authenticated_userid
    • Sets request.effective_principals

24 of 34

Resources

25 of 34

Routing sets the resource factory

The routing (routes.py) sets the resource factory for each route:

config.add_route("lti_launches", "/lti_launches", factory="lms.resources.LTILaunchResource")

config.add_route("module_item_configurations", …, factory="lms.resources.LTILaunchResource")

config.add_route("content_item_selection", …, factory="lms.resources.LTILaunchResource")

26 of 34

LTILaunchResource

LTILaunchResource is the “context resource” for LTI launch requests:

class LTILaunchResource:

__acl__ = [(Allow, "lti_user", "launch_lti_assignment")]

@property

def h_display_name(self):

"""Return the h user display name for the current request."""

...

@property

def h_groupid(self):

@property

def h_group_name(self):

...

27 of 34

permission view predicate (standard)

@view_defaults(..., permission="launch_lti_assignment", ...)

class BasicLTILaunchViews:

...

28 of 34

Views

29 of 34

Basic Views

  • index.py
    • The (empty) front page
  • application_instances.py
    • The /welcome page
  • config.py
    • The /config_xml page
  • authentication.py
    • For logging into and out of the /reports page. This isn’t used for any other authentication (it’s not used for LTI launches, JWT API requests, or authenticating to the h and Canvas APIs)
  • reports.py
    • the /reports page. This is the page that you login to and out of. For Jeremy only, not users
  • status.py
    • the /status page (health check URL)
  • favicon.py

30 of 34

More Important Views

  • basic_lti_launch.py
    • The views that receive LTI launches�
  • content_item_selection.py
    • The view for creating new assignments in Canvas (another type of LTI launch)�
  • views/api/canvas/
    • The various endpoints of the Canvas proxy API

31 of 34

BasicLTILaunchViews

A class containing view methods for all the similar-but-not-quite-the-same ways of launching different kinds of assignment:

@view_defaults(permission="launch_lti_assignment",

renderer="lms:templates/basic_lti_launch/basic_lti_launch.html.jinja2",

request_method="POST",

route_name="lti_launches")

class BasicLTILaunchViews:

def canvas_file_basic_lti_launch(self):

def db_configured_basic_lti_launch(self):

def url_configured_basic_lti_launch(self):

def unconfigured_basic_lti_launch(self):

def unconfigured_basic_lti_launch_not_authorized(self):

def configure_module_item(self):

32 of 34

Custom View Predicates

@view_config(canvas_file=True, …)

def canvas_file_basic_lti_launch(self):

@view_config(db_configured=True, …)

def db_configured_basic_lti_launch(self):

@view_config(url_configured=True, …)

def url_configured_basic_lti_launch(self):

@view_config(authorized_to_configure_assignments=True, configured=False, …)

def unconfigured_basic_lti_launch(self):

@view_config(authorized_to_configure_assignments=False, configured=False, …)

def unconfigured_basic_lti_launch_not_authorized(self)��See lms/views/predicates/ for the implementations of these custom view predicates.

33 of 34

Custom View Decorators

@view_defaults(decorator=[upsert_h_user, upsert_course_group, add_user_to_group], …)

class BasicLTILaunchViews:

@view_config(decorator=[upsert_h_user, upsert_course_group], …)

def content_item_selection(context, request):

See lms/views/decorators/

34 of 34

Error Views

  • lms/views/error.py
  • lms/views/api/error.py�
  • These catch exceptions (e.g. exceptions raised by services, or validation) and turn them into the appropriate error responses
  • Different error views for different exception classes, where different error responses are needed