diff --git a/lti_consumer/admin.py b/lti_consumer/admin.py
index d39e2062d1e3f479c5b3d14830db7e6f3b0b2640..a942a88959d54a4af632ea6ee6cb514787ad8b57 100644
--- a/lti_consumer/admin.py
+++ b/lti_consumer/admin.py
@@ -2,7 +2,7 @@
 Admin views for LTI related models.
 """
 from django.contrib import admin
-from lti_consumer.models import LtiConfiguration
+from lti_consumer.models import LtiAgsLineItem, LtiConfiguration
 
 
 class LtiConfigurationAdmin(admin.ModelAdmin):
@@ -15,3 +15,4 @@ class LtiConfigurationAdmin(admin.ModelAdmin):
 
 
 admin.site.register(LtiConfiguration, LtiConfigurationAdmin)
+admin.site.register(LtiAgsLineItem)
diff --git a/lti_consumer/lti_1p3/ags.py b/lti_consumer/lti_1p3/ags.py
new file mode 100644
index 0000000000000000000000000000000000000000..5fbb459bea0ba6ce656b866b09a758cf90526861
--- /dev/null
+++ b/lti_consumer/lti_1p3/ags.py
@@ -0,0 +1,70 @@
+"""
+LTI Advantage Assignments and Grades service implementation
+"""
+
+
+class LtiAgs:
+    """
+    LTI Advantage Consumer
+
+    Implements LTI Advantage Services and ties them in
+    with the LTI Consumer. This only handles the LTI
+    message claim inclusion and token handling.
+
+    Available services:
+    * Assignments and Grades services (partial support)
+
+    Reference: https://www.imsglobal.org/lti-advantage-overview
+    """
+    def __init__(
+        self,
+        lineitems_url,
+        allow_creating_lineitems=True,
+        results_service_enabled=True,
+        scores_service_enabled=True
+    ):
+        """
+        Instance class with LTI AGS Global settings.
+        """
+        # If the platform allows creating lineitems, set this
+        # to True.
+        self.allow_creating_lineitems = allow_creating_lineitems
+
+        # Result and scores services
+        self.results_service_enabled = results_service_enabled
+        self.scores_service_enabled = scores_service_enabled
+
+        # Lineitems urls
+        self.lineitems_url = lineitems_url
+
+    def get_available_scopes(self):
+        """
+        Retrieves list of available token scopes in this instance.
+        """
+        scopes = []
+
+        if self.allow_creating_lineitems:
+            scopes.append('https://purl.imsglobal.org/spec/lti-ags/scope/lineitem')
+        else:
+            scopes.append('https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly')
+
+        if self.results_service_enabled:
+            scopes.append('https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly')
+
+        if self.scores_service_enabled:
+            scopes.append('https://purl.imsglobal.org/spec/lti-ags/scope/score')
+
+        return scopes
+
+    def get_lti_ags_launch_claim(self):
+        """
+        Returns LTI AGS Claim to be injected in the LTI launch message.
+        """
+        ags_claim = {
+            "https://purl.imsglobal.org/spec/lti-ags/claim/endpoint": {
+                "scope": self.get_available_scopes(),
+                "lineitems": self.lineitems_url,
+            }
+        }
+
+        return ags_claim
diff --git a/lti_consumer/lti_1p3/constants.py b/lti_consumer/lti_1p3/constants.py
index de29ef7e54cb50e861b9db1fab63ad507cc947a6..344978d6977650d06654652941a1cd0794a8b4de 100644
--- a/lti_consumer/lti_1p3/constants.py
+++ b/lti_consumer/lti_1p3/constants.py
@@ -42,7 +42,12 @@ LTI_1P3_ACCESS_TOKEN_REQUIRED_CLAIMS = set([
     "scope",
 ])
 
-LTI_1P3_ACCESS_TOKEN_SCOPES = []
+
+LTI_1P3_ACCESS_TOKEN_SCOPES = [
+    # LTI-AGS Scopes
+    'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly',
+    'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem',
+]
 
 
 class LTI_1P3_CONTEXT_TYPE(Enum):  # pylint: disable=invalid-name
diff --git a/lti_consumer/lti_1p3/consumer.py b/lti_consumer/lti_1p3/consumer.py
index c5904bd5df857a0362a12f19468307ecdf7b3394..6cd2c8abbd4795d53e90401ab986753162944ac2 100644
--- a/lti_consumer/lti_1p3/consumer.py
+++ b/lti_consumer/lti_1p3/consumer.py
@@ -12,6 +12,7 @@ from .constants import (
     LTI_1P3_CONTEXT_TYPE,
 )
 from .key_handlers import ToolKeyHandler, PlatformKeyHandler
+from .ags import LtiAgs
 
 
 class LtiConsumer1p3:
@@ -436,3 +437,57 @@ class LtiConsumer1p3:
         if not isinstance(claim, dict):
             raise ValueError('Invalid extra claim: is not a dict.')
         self.extra_claims.update(claim)
+
+
+class LtiAdvantageConsumer(LtiConsumer1p3):
+    """
+    LTI Advantage  Consumer Implementation.
+
+    Builds on top of the LTI 1.3 consumer and adds support for
+    the following LTI Advantage Services:
+
+    * Assignments and Grades Service (LTI-AGS): Allows tools to
+      retrieve and send back grades into the platform.
+      Note: this is a partial implementation with read-only LineItems.
+      Reference spec: https://www.imsglobal.org/spec/lti-ags/v2p0
+    """
+    def __init__(self, *args, **kwargs):
+        """
+        Override parent class and set up required LTI Advantage variables.
+        """
+        super(LtiAdvantageConsumer, self).__init__(*args, **kwargs)
+
+        # LTI AGS Variables
+        self.ags = None
+
+    @property
+    def lti_ags(self):
+        """
+        Returns LTI AGS class or throw exception if not set up.
+        """
+        if not self.ags:
+            raise exceptions.LtiAdvantageServiceNotSetUp(
+                "The LTI AGS service was not set up for this consumer."
+            )
+
+        return self.ags
+
+    def enable_ags(
+        self,
+        lineitems_url,
+    ):
+        """
+        Enable LTI Advantage Assignments and Grades Service.
+
+        This will include the LTI AGS Claim in the LTI message
+        and set up the required class.
+        """
+        self.ags = LtiAgs(
+            lineitems_url=lineitems_url,
+            allow_creating_lineitems=True,
+            results_service_enabled=True,
+            scores_service_enabled=True
+        )
+
+        # Include LTI AGS claim inside the LTI Launch message
+        self.set_extra_claim(self.ags.get_lti_ags_launch_claim())
diff --git a/lti_consumer/lti_1p3/exceptions.py b/lti_consumer/lti_1p3/exceptions.py
index b75a3011b36311b90035ad930c324954901dad1f..e895567dac2014c5f39dfba446a20a5fa4241047 100644
--- a/lti_consumer/lti_1p3/exceptions.py
+++ b/lti_consumer/lti_1p3/exceptions.py
@@ -51,3 +51,7 @@ class RsaKeyNotSet(Lti1p3Exception):
 
 class PreflightRequestValidationFailure(Lti1p3Exception):
     pass
+
+
+class LtiAdvantageServiceNotSetUp(Lti1p3Exception):
+    pass
diff --git a/lti_consumer/lti_1p3/extensions/rest_framework/parsers.py b/lti_consumer/lti_1p3/extensions/rest_framework/parsers.py
new file mode 100644
index 0000000000000000000000000000000000000000..836f3622e5e406191d055932fa953d25e067bfe7
--- /dev/null
+++ b/lti_consumer/lti_1p3/extensions/rest_framework/parsers.py
@@ -0,0 +1,16 @@
+"""
+LTI 1.3 Django extensions - Content parsers
+
+Used by DRF views to render content in LTI APIs.
+"""
+
+from rest_framework import parsers
+
+
+class LineItemParser(parsers.JSONParser):
+    """
+    Line Item Parser.
+
+    It's the same as JSON parser, but uses a custom media_type.
+    """
+    media_type = 'application/vnd.ims.lis.v2.lineitem+json'
diff --git a/lti_consumer/lti_1p3/extensions/rest_framework/permissions.py b/lti_consumer/lti_1p3/extensions/rest_framework/permissions.py
new file mode 100644
index 0000000000000000000000000000000000000000..c24bda8ad1d46c6f4c629cb739e5dfa8d9ce1a8e
--- /dev/null
+++ b/lti_consumer/lti_1p3/extensions/rest_framework/permissions.py
@@ -0,0 +1,47 @@
+"""
+Django REST Framework extensions for LTI 1.3 & LTI Advantage implementation.
+
+Implements a custom authorization classes to be used by any of the
+LTI Advantage extensions.
+"""
+from rest_framework import permissions
+
+
+class LtiAgsPermissions(permissions.BasePermission):
+    """
+    LTI AGS Permissions.
+
+    This checks if the token included in the request
+    has the allowed scopes to read/write LTI AGS items
+    (LineItems, Results, Score).
+
+    LineItem scopes: https://www.imsglobal.org/spec/lti-ags/v2p0#scope-and-allowed-http-methods
+    Results: Not implemented yet.
+    Score: Not implemented yet.
+    """
+    def has_permission(self, request, view):
+        """
+        Check if LTI AGS permissions are set in auth token.
+        """
+        has_perm = False
+
+        # Retrieves token from request, which was already checked by
+        # the Authentication class, so we assume it's a sane value.
+        auth_token = request.headers['Authorization'].split()[1]
+
+        if view.action in ['list', 'retrieve']:
+            # We don't need to wrap this around a try-catch because
+            # the token was already tested by the Authentication class.
+            has_perm = request.lti_consumer.check_token(
+                auth_token,
+                [
+                    'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly',
+                    'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem',
+                ],
+            )
+        elif view.action in ['create', 'update', 'partial_update', 'delete']:
+            has_perm = request.lti_consumer.check_token(
+                auth_token,
+                ['https://purl.imsglobal.org/spec/lti-ags/scope/lineitem']
+            )
+        return has_perm
diff --git a/lti_consumer/lti_1p3/extensions/rest_framework/renderers.py b/lti_consumer/lti_1p3/extensions/rest_framework/renderers.py
new file mode 100644
index 0000000000000000000000000000000000000000..db71c6317da898665767f99176a9d9efeb95ce71
--- /dev/null
+++ b/lti_consumer/lti_1p3/extensions/rest_framework/renderers.py
@@ -0,0 +1,28 @@
+"""
+LTI 1.3 Django extensions - Content renderers
+
+Used by DRF views to render content in LTI APIs.
+"""
+from rest_framework import renderers
+
+
+class LineItemsRenderer(renderers.JSONRenderer):
+    """
+    Line Items Renderer.
+
+    It's a JSON renderer, but uses a custom media_type.
+    Reference: https://www.imsglobal.org/spec/lti-ags/v2p0#media-types-and-schemas
+    """
+    media_type = 'application/vnd.ims.lis.v2.lineitemcontainer+json'
+    format = 'json'
+
+
+class LineItemRenderer(renderers.JSONRenderer):
+    """
+    Line Item Renderer.
+
+    It's a JSON renderer, but uses a custom media_type.
+    Reference: https://www.imsglobal.org/spec/lti-ags/v2p0#media-types-and-schemas
+    """
+    media_type = 'application/vnd.ims.lis.v2.lineitem+json'
+    format = 'json'
diff --git a/lti_consumer/lti_1p3/extensions/rest_framework/serializers.py b/lti_consumer/lti_1p3/extensions/rest_framework/serializers.py
new file mode 100644
index 0000000000000000000000000000000000000000..d173661153e895eaf1eeb3ec1a4f7084ae8b709d
--- /dev/null
+++ b/lti_consumer/lti_1p3/extensions/rest_framework/serializers.py
@@ -0,0 +1,91 @@
+"""
+Serializers for LTI-related endpoints
+"""
+from rest_framework import serializers
+from rest_framework.reverse import reverse
+from opaque_keys import InvalidKeyError
+from opaque_keys.edx.keys import UsageKey
+
+from lti_consumer.models import LtiAgsLineItem
+
+
+class UsageKeyField(serializers.Field):
+    """
+    Serializer field for a model UsageKey field.
+
+    Recreated here since we cannot import directly from
+    from the platform like so:
+    `from openedx.core.lib.api.serializers import UsageKeyField`
+    """
+    # pylint: disable=arguments-differ
+    def to_representation(self, data):
+        """
+        Convert a usage key to unicode.
+        """
+        return str(data)
+
+    def to_internal_value(self, data):
+        """
+        Convert unicode to a usage key.
+        """
+        try:
+            return UsageKey.from_string(data)
+        except InvalidKeyError:
+            raise serializers.ValidationError("Invalid usage key: {}".format(data))
+
+
+class LtiAgsLineItemSerializer(serializers.ModelSerializer):
+    """
+    LTI AGS LineItem Serializer.
+
+    This maps out the internally stored LineItemParameters to
+    the LTI-AGS API Specification, as shown in the example
+    response below:
+
+    {
+        "id" : "https://lms.example.com/context/2923/lineitems/1",
+        "scoreMaximum" : 60,
+        "label" : "Chapter 5 Test",
+        "resourceId" : "a-9334df-33",
+        "tag" : "grade",
+        "resourceLinkId" : "1g3k4dlk49fk",
+        "startDateTime": "2018-03-06T20:05:02Z",
+        "endDateTime": "2018-04-06T22:05:03Z",
+    }
+
+    Reference:
+    https://www.imsglobal.org/spec/lti-ags/v2p0#example-application-vnd-ims-lis-v2-lineitem-json-representation
+    """
+    # Id needs to be overriden and be a URL to the LineItem endpoint
+    id = serializers.SerializerMethodField()
+
+    # Mapping from snake_case to camelCase
+    resourceId = serializers.CharField(source='resource_id')
+    scoreMaximum = serializers.IntegerField(source='score_maximum')
+    resourceLinkId = UsageKeyField(required=False, source='resource_link_id')
+    startDateTime = serializers.DateTimeField(required=False, source='start_date_time')
+    endDateTime = serializers.DateTimeField(required=False, source='end_date_time')
+
+    def get_id(self, obj):
+        request = self.context.get('request')
+        return reverse(
+            'lti_consumer:lti-ags-view-detail',
+            kwargs={
+                'lti_config_id': obj.lti_configuration.id,
+                'pk': obj.pk
+            },
+            request=request,
+        )
+
+    class Meta:
+        model = LtiAgsLineItem
+        fields = (
+            'id',
+            'resourceId',
+            'scoreMaximum',
+            'label',
+            'tag',
+            'resourceLinkId',
+            'startDateTime',
+            'endDateTime',
+        )
diff --git a/lti_consumer/lti_1p3/tests/extensions/rest_framework/test_permissions.py b/lti_consumer/lti_1p3/tests/extensions/rest_framework/test_permissions.py
new file mode 100644
index 0000000000000000000000000000000000000000..fbe7a5cef6e9adeb549f8e6aa33d9608a46dcfad
--- /dev/null
+++ b/lti_consumer/lti_1p3/tests/extensions/rest_framework/test_permissions.py
@@ -0,0 +1,184 @@
+"""
+Unit tests for LTI 1.3 consumer implementation
+"""
+from __future__ import absolute_import, unicode_literals
+
+import ddt
+from mock import MagicMock
+
+from Cryptodome.PublicKey import RSA
+from django.test.testcases import TestCase
+
+from lti_consumer.models import LtiConfiguration
+from lti_consumer.lti_1p3.consumer import LtiConsumer1p3
+from lti_consumer.lti_1p3.extensions.rest_framework.permissions import LtiAgsPermissions
+
+
+# Variables required for testing and verification
+ISS = "http://test-platform.example/"
+OIDC_URL = "http://test-platform/oidc"
+LAUNCH_URL = "http://test-platform/launch"
+CLIENT_ID = "1"
+DEPLOYMENT_ID = "1"
+NONCE = "1234"
+STATE = "ABCD"
+# Consider storing a fixed key
+RSA_KEY_ID = "1"
+RSA_KEY = RSA.generate(2048).export_key('PEM')
+
+
+@ddt.ddt
+class TestLtiAuthentication(TestCase):
+    """
+    Unit tests for Lti1p3ApiAuthentication class
+    """
+    def setUp(self):
+        super(TestLtiAuthentication, self).setUp()
+
+        # Set up consumer
+        self.lti_consumer = LtiConsumer1p3(
+            iss=ISS,
+            lti_oidc_url=OIDC_URL,
+            lti_launch_url=LAUNCH_URL,
+            client_id=CLIENT_ID,
+            deployment_id=DEPLOYMENT_ID,
+            rsa_key=RSA_KEY,
+            rsa_key_id=RSA_KEY_ID,
+            # Use the same key for testing purposes
+            tool_key=RSA_KEY
+        )
+
+        # Create LTI Configuration
+        self.lti_configuration = LtiConfiguration.objects.create(
+            version=LtiConfiguration.LTI_1P3,
+        )
+
+        # Create mock request
+        self.mock_request = MagicMock()
+        self.mock_request.lti_consumer = self.lti_consumer
+
+    def _make_token(self, scopes):
+        """
+        Return a valid token with the required scopes.
+        """
+        # Generate a valid access token
+        return self.lti_consumer.key_handler.encode_and_sign(
+            {
+                "sub": self.lti_consumer.client_id,
+                "iss": self.lti_consumer.iss,
+                "scopes": " ".join(scopes),
+            },
+            expiration=3600
+        )
+
+    @ddt.data(
+        ["https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly"],
+        ["https://purl.imsglobal.org/spec/lti-ags/scope/lineitem"],
+        [
+            "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly",
+            "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem",
+        ]
+    )
+    def test_read_only_lineitem_list(self, token_scopes):
+        """
+        Test if LineItem is readable when any of the allowed scopes is
+        included in the token.
+        """
+        perm_class = LtiAgsPermissions()
+        mock_view = MagicMock()
+
+        # Make token and include it in the mock request
+        token = self._make_token(token_scopes)
+        self.mock_request.headers = {
+            "Authorization": "Bearer {}".format(token)
+        }
+
+        # Test list view
+        mock_view.action = 'list'
+        self.assertTrue(
+            perm_class.has_permission(self.mock_request, mock_view),
+        )
+
+        # Test retrieve view
+        mock_view.action = 'retrieve'
+        self.assertTrue(
+            perm_class.has_permission(self.mock_request, mock_view),
+        )
+
+    def test_lineitem_no_permissions(self):
+        """
+        Test if LineItem is readable when any of the allowed scopes is
+        included in the token.
+        """
+        perm_class = LtiAgsPermissions()
+        mock_view = MagicMock()
+
+        # Make token and include it in the mock request
+        token = self._make_token([])
+        self.mock_request.headers = {
+            "Authorization": "Bearer {}".format(token)
+        }
+
+        # Test list view
+        mock_view.action = 'list'
+        self.assertFalse(
+            perm_class.has_permission(self.mock_request, mock_view),
+        )
+
+        # Test retrieve view
+        mock_view.action = 'retrieve'
+        self.assertFalse(
+            perm_class.has_permission(self.mock_request, mock_view),
+        )
+
+    @ddt.data(
+        (["https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly"], False),
+        (["https://purl.imsglobal.org/spec/lti-ags/scope/lineitem"], True),
+        (
+            [
+                "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly",
+                "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem",
+            ],
+            True
+        )
+    )
+    @ddt.unpack
+    def test_lineitem_write_permissions(self, token_scopes, is_allowed):
+        """
+        Test if write operations on LineItem are allowed with the correct token.
+        """
+        perm_class = LtiAgsPermissions()
+        mock_view = MagicMock()
+
+        # Make token and include it in the mock request
+        token = self._make_token(token_scopes)
+        self.mock_request.headers = {
+            "Authorization": "Bearer {}".format(token)
+        }
+
+        for action in ['create', 'update', 'partial_update', 'delete']:
+            # Test list view
+            mock_view.action = action
+            self.assertEqual(
+                perm_class.has_permission(self.mock_request, mock_view),
+                is_allowed
+            )
+
+    def test_unregistered_action_not_allowed(self):
+        """
+        Test unauthorized when trying to post to unregistered action.
+        """
+        perm_class = LtiAgsPermissions()
+        mock_view = MagicMock()
+
+        # Make token and include it in the mock request
+        token = self._make_token([])
+        self.mock_request.headers = {
+            "Authorization": "Bearer {}".format(token)
+        }
+
+        # Test list view
+        mock_view.action = 'invalid-action'
+        self.assertFalse(
+            perm_class.has_permission(self.mock_request, mock_view),
+        )
diff --git a/lti_consumer/lti_1p3/tests/test_ags.py b/lti_consumer/lti_1p3/tests/test_ags.py
new file mode 100644
index 0000000000000000000000000000000000000000..94f4cd30f794fe294f5939df81e7de11e158f5f8
--- /dev/null
+++ b/lti_consumer/lti_1p3/tests/test_ags.py
@@ -0,0 +1,72 @@
+"""
+Unit tests for LTI 1.3 consumer implementation
+"""
+from __future__ import absolute_import, unicode_literals
+
+from django.test.testcases import TestCase
+
+from lti_consumer.lti_1p3.ags import LtiAgs
+
+
+class TestLtiAgs(TestCase):
+    """
+    Unit tests for LtiAgs class
+    """
+    def test_instance_ags_no_permissions(self):
+        """
+        Test enabling LTI AGS with no permissions.
+        """
+        ags = LtiAgs(
+            "http://example.com/lineitem",
+            allow_creating_lineitems=False,
+            results_service_enabled=False,
+            scores_service_enabled=False
+        )
+        scopes = ags.get_available_scopes()
+
+        # Disabling all permissions will only allow the tool to
+        # list and retrieve LineItems
+        self.assertEqual(
+            scopes,
+            ['https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly'],
+        )
+
+    def test_instance_ags_all_permissions(self):
+        """
+        Test enabling LTI AGS with all permissions.
+        """
+        ags = LtiAgs(
+            "http://example.com/lineitem",
+            allow_creating_lineitems=True,
+            results_service_enabled=True,
+            scores_service_enabled=True
+        )
+        scopes = ags.get_available_scopes()
+
+        # Check available scopes
+        self.assertIn('https://purl.imsglobal.org/spec/lti-ags/scope/lineitem', scopes)
+        self.assertIn('https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly', scopes)
+        self.assertIn('https://purl.imsglobal.org/spec/lti-ags/scope/score', scopes)
+
+    def test_get_lti_ags_launch_claim(self):
+        """
+        Test if the launch claim is properly formed
+        """
+        ags = LtiAgs(
+            "http://example.com/lineitem",
+            allow_creating_lineitems=False,
+            results_service_enabled=False,
+            scores_service_enabled=False
+        )
+
+        self.assertEqual(
+            ags.get_lti_ags_launch_claim(),
+            {
+                "https://purl.imsglobal.org/spec/lti-ags/claim/endpoint": {
+                    "scope": [
+                        "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly"
+                    ],
+                    "lineitems": "http://example.com/lineitem",
+                }
+            }
+        )
diff --git a/lti_consumer/lti_1p3/tests/test_consumer.py b/lti_consumer/lti_1p3/tests/test_consumer.py
index 9ffc157b87fed7f9415eb94022917b680310fcce..dfae8e16663efb0ddff988ed9b83cb2b8d899a1a 100644
--- a/lti_consumer/lti_1p3/tests/test_consumer.py
+++ b/lti_consumer/lti_1p3/tests/test_consumer.py
@@ -15,7 +15,8 @@ from jwkest.jwk import load_jwks
 from jwkest.jws import JWS
 
 from lti_consumer.lti_1p3.constants import LTI_1P3_CONTEXT_TYPE
-from lti_consumer.lti_1p3.consumer import LtiConsumer1p3
+from lti_consumer.lti_1p3.consumer import LtiConsumer1p3, LtiAdvantageConsumer
+from lti_consumer.lti_1p3.ags import LtiAgs
 from lti_consumer.lti_1p3 import exceptions
 
 
@@ -548,3 +549,109 @@ class TestLti1p3Consumer(TestCase):
         """
         with self.assertRaises(ValueError):
             self.lti_consumer.set_extra_claim(test_value)
+
+
+@ddt.ddt
+class TestLtiAdvantageConsumer(TestCase):
+    """
+    Unit tests for LtiAdvantageConsumer
+    """
+    def setUp(self):
+        super(TestLtiAdvantageConsumer, self).setUp()
+
+        # Set up consumer
+        self.lti_consumer = LtiAdvantageConsumer(
+            iss=ISS,
+            lti_oidc_url=OIDC_URL,
+            lti_launch_url=LAUNCH_URL,
+            client_id=CLIENT_ID,
+            deployment_id=DEPLOYMENT_ID,
+            rsa_key=RSA_KEY,
+            rsa_key_id=RSA_KEY_ID,
+            # Use the same key for testing purposes
+            tool_key=RSA_KEY
+        )
+
+    def _setup_lti_user(self):
+        """
+        Set up a minimal LTI message with only required parameters.
+
+        Currently, the only required parameters are the user data,
+        but using a helper function to keep the usage consistent accross
+        all tests.
+        """
+        self.lti_consumer.set_user_data(
+            user_id="1",
+            role="student",
+        )
+
+    def _get_lti_message(
+            self,
+            preflight_response=None,
+            resource_link="link"
+    ):
+        """
+        Retrieves a base LTI message with fixed test parameters.
+
+        This function has valid default values, so it can be used to test custom
+        parameters, but allows overriding them.
+        """
+        if preflight_response is None:
+            preflight_response = {
+                "client_id": CLIENT_ID,
+                "redirect_uri": LAUNCH_URL,
+                "nonce": NONCE,
+                "state": STATE
+            }
+
+        return self.lti_consumer.generate_launch_request(
+            preflight_response,
+            resource_link
+        )
+
+    def _decode_token(self, token):
+        """
+        Checks for a valid signarute and decodes JWT signed LTI message
+
+        This also tests the public keyset function.
+        """
+        public_keyset = self.lti_consumer.get_public_keyset()
+        key_set = load_jwks(json.dumps(public_keyset))
+
+        return JWS().verify_compact(token, keys=key_set)
+
+    def test_no_ags_returns_failure(self):
+        """
+        Test that when LTI-AGS isn't configured, the class yields an error.
+        """
+        with self.assertRaises(exceptions.LtiAdvantageServiceNotSetUp):
+            self.lti_consumer.lti_ags  # pylint: disable=pointless-statement
+
+    def test_enable_ags(self):
+        """
+        Test enabling LTI AGS and checking that required parameters are set.
+        """
+        self.lti_consumer.enable_ags("http://example.com/lineitems")
+
+        # Check that the AGS class was properly instanced and set
+        self.assertEqual(type(self.lti_consumer.ags), LtiAgs)
+
+        # Check retrieving class works
+        lti_ags_class = self.lti_consumer.lti_ags
+        self.assertEqual(self.lti_consumer.ags, lti_ags_class)
+
+        # Check that enabling the AGS adds the LTI AGS claim
+        # in the launch message
+        self.assertEqual(
+            self.lti_consumer.extra_claims,
+            {
+                'https://purl.imsglobal.org/spec/lti-ags/claim/endpoint': {
+                    'scope': [
+                        'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem',
+                        'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly',
+                        'https://purl.imsglobal.org/spec/lti-ags/scope/score'
+                    ],
+                    'lineitems': 'http://example.com/lineitems'
+                }
+            }
+        )
diff --git a/lti_consumer/migrations/0002_ltiagslineitem.py b/lti_consumer/migrations/0002_ltiagslineitem.py
new file mode 100644
index 0000000000000000000000000000000000000000..42391398cb219fff57d10b3914f4b5e52f43b0d1
--- /dev/null
+++ b/lti_consumer/migrations/0002_ltiagslineitem.py
@@ -0,0 +1,29 @@
+# Generated by Django 2.2.16 on 2020-09-29 21:48
+
+from django.db import migrations, models
+import django.db.models.deletion
+import opaque_keys.edx.django.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('lti_consumer', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='LtiAgsLineItem',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('resource_id', models.CharField(blank=True, max_length=100)),
+                ('resource_link_id', opaque_keys.edx.django.models.UsageKeyField(blank=True, db_index=True, max_length=255, null=True)),
+                ('label', models.CharField(max_length=100)),
+                ('score_maximum', models.IntegerField()),
+                ('tag', models.CharField(blank=True, max_length=50)),
+                ('start_date_time', models.DateTimeField(blank=True, null=True)),
+                ('end_date_time', models.DateTimeField(blank=True, null=True)),
+                ('lti_configuration', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='lti_consumer.LtiConfiguration')),
+            ],
+        ),
+    ]
diff --git a/lti_consumer/models.py b/lti_consumer/models.py
index 85a72a916ca3218e139847b60c3457d8c943ba5c..882b7239a713329250a3c0f9d96a21df502c213c 100644
--- a/lti_consumer/models.py
+++ b/lti_consumer/models.py
@@ -8,8 +8,8 @@ from opaque_keys.edx.django.models import UsageKeyField
 # LTI 1.1
 from lti_consumer.lti_1p1.consumer import LtiConsumer1p1
 # LTI 1.3
-from lti_consumer.lti_1p3.consumer import LtiConsumer1p3
-from lti_consumer.utils import get_lms_base
+from lti_consumer.lti_1p3.consumer import LtiAdvantageConsumer
+from lti_consumer.utils import get_lms_base, get_lti_ags_lineitems_url
 
 
 class LtiConfiguration(models.Model):
@@ -112,7 +112,7 @@ class LtiConfiguration(models.Model):
         """
         # If LTI configuration is stored in the XBlock.
         if self.config_store == self.CONFIG_ON_XBLOCK:
-            consumer = LtiConsumer1p3(
+            consumer = LtiAdvantageConsumer(
                 iss=get_lms_base(),
                 lti_oidc_url=self.block.lti_1p3_oidc_url,
                 lti_launch_url=self.block.lti_1p3_launch_url,
@@ -128,6 +128,12 @@ class LtiConfiguration(models.Model):
                 tool_keyset_url=None,
             )
 
+            # Check if enabled and setup LTI-AGS
+            if self.block.has_score:
+                consumer.enable_ags(
+                    lineitems_url=get_lti_ags_lineitems_url(self.id)
+                )
+
             return consumer
 
         # There's no configuration stored locally, so throw
@@ -145,3 +151,56 @@ class LtiConfiguration(models.Model):
 
     def __str__(self):
         return "[{}] {} - {}".format(self.config_store, self.version, self.location)
+
+
+class LtiAgsLineItem(models.Model):
+    """
+    Model to store LineItem data for LTI Assignments and Grades service.
+
+    LTI-AGS Specification: https://www.imsglobal.org/spec/lti-ags/v2p0
+    The platform MUST NOT modify the 'resourceId', 'resourceLinkId' and 'tag' values.
+
+    Note: When implementing multi-tenancy support, this needs to be changed
+    and be tied to a deployment ID, because each deployment should isolate
+    it's resources.
+
+    .. no_pii:
+    """
+    # LTI Configuration link
+    # This ties the LineItem to each tool configuration
+    # and allows easily retrieving LTI credentials for
+    # API authentication.
+    lti_configuration = models.ForeignKey(
+        LtiConfiguration,
+        on_delete=models.CASCADE,
+        null=True,
+        blank=True
+    )
+
+    # Tool resource identifier, not used by the LMS.
+    resource_id = models.CharField(max_length=100, blank=True)
+
+    # LMS Resource link
+    # Must be the same as the one sent in the tool's LTI launch.
+    # Each LineItem created by a tool should be specific to the
+    # context from which it was created.
+    # Currently it maps to a block using a usagekey
+    resource_link_id = UsageKeyField(
+        max_length=255,
+        db_index=True,
+        null=True,
+        blank=True,
+    )
+
+    # Other LineItem attributes
+    label = models.CharField(max_length=100)
+    score_maximum = models.IntegerField()
+    tag = models.CharField(max_length=50, blank=True)
+    start_date_time = models.DateTimeField(blank=True, null=True)
+    end_date_time = models.DateTimeField(blank=True, null=True)
+
+    def __str__(self):
+        return "{} - {}".format(
+            self.resource_link_id,
+            self.label,
+        )
diff --git a/lti_consumer/plugin/urls.py b/lti_consumer/plugin/urls.py
index 30ea59732af3da6f91d2e98af0b581a735dd658b..1cf1d7f563db419ed0c5ed3dc31ee688370cff18 100644
--- a/lti_consumer/plugin/urls.py
+++ b/lti_consumer/plugin/urls.py
@@ -12,14 +12,18 @@ from rest_framework import routers
 from lti_consumer.plugin.views import (
     public_keyset_endpoint,
     launch_gate_endpoint,
-    access_token_endpoint
+    access_token_endpoint,
+    # LTI Advantage URLs
+    LtiAgsLineItemViewset,
 )
 
 
 # LTI 1.3 APIs router
 router = routers.SimpleRouter(trailing_slash=False)
+router.register(r'lti-ags', LtiAgsLineItemViewset, basename='lti-ags-view')
 
 
+app_name = 'lti_consumer'
 urlpatterns = [
     url(
         'lti_consumer/v1/public_keysets/{}$'.format(settings.USAGE_ID_PATTERN),
diff --git a/lti_consumer/plugin/views.py b/lti_consumer/plugin/views.py
index 78174a50090221e8debafd443219ed350145ef90..a90febb1dc9d1ab70b5ac9967bb71368f7274162 100644
--- a/lti_consumer/plugin/views.py
+++ b/lti_consumer/plugin/views.py
@@ -1,12 +1,19 @@
 """
 LTI consumer plugin passthrough views
 """
-
 from django.http import HttpResponse
 from django.views.decorators.csrf import csrf_exempt
 from django.views.decorators.http import require_http_methods
-
+from django_filters.rest_framework import DjangoFilterBackend
 from opaque_keys.edx.keys import UsageKey
+from rest_framework import viewsets
+
+from lti_consumer.models import LtiAgsLineItem
+from lti_consumer.lti_1p3.extensions.rest_framework.serializers import LtiAgsLineItemSerializer
+from lti_consumer.lti_1p3.extensions.rest_framework.permissions import LtiAgsPermissions
+from lti_consumer.lti_1p3.extensions.rest_framework.authentication import Lti1p3ApiAuthentication
+from lti_consumer.lti_1p3.extensions.rest_framework.renderers import LineItemsRenderer, LineItemRenderer
+from lti_consumer.lti_1p3.extensions.rest_framework.parsers import LineItemParser
 from lti_consumer.plugin.compat import (
     run_xblock_handler,
     run_xblock_handler_noauth,
@@ -77,3 +84,49 @@ def access_token_endpoint(request, usage_id=None):
         )
     except Exception:  # pylint: disable=broad-except
         return HttpResponse(status=404)
+
+
+class LtiAgsLineItemViewset(viewsets.ModelViewSet):
+    """
+    LineItem endpoint implementation from LTI Advantage.
+
+    See full documentation at:
+    https://www.imsglobal.org/spec/lti-ags/v2p0#line-item-service
+    """
+    serializer_class = LtiAgsLineItemSerializer
+    pagination_class = None
+
+    # Custom permission classes for LTI APIs
+    authentication_classes = [Lti1p3ApiAuthentication]
+    permission_classes = [LtiAgsPermissions]
+
+    # Renderer/parser classes to accept LTI AGS content types
+    renderer_classes = [
+        LineItemsRenderer,
+        LineItemRenderer,
+    ]
+    parser_classes = [LineItemParser]
+
+    # Filters
+    filter_backends = [DjangoFilterBackend]
+    filterset_fields = [
+        'resource_link_id',
+        'resource_id',
+        'tag'
+    ]
+
+    def get_queryset(self):
+        lti_configuration = self.request.lti_configuration
+
+        # Return all LineItems related to the LTI configuration.
+        # TODO:
+        # Note that each configuration currently maps 1:1
+        # to each resource link (block), and this filter needs
+        # improved once we start reusing LTI configurations.
+        return LtiAgsLineItem.objects.filter(
+            lti_configuration=lti_configuration
+        )
+
+    def perform_create(self, serializer):
+        lti_configuration = self.request.lti_configuration
+        serializer.save(lti_configuration=lti_configuration)
diff --git a/lti_consumer/tests/unit/plugin/test_views_lti_ags.py b/lti_consumer/tests/unit/plugin/test_views_lti_ags.py
new file mode 100644
index 0000000000000000000000000000000000000000..20bbc4ac40ef98c58ebe51a25d694205e170c722
--- /dev/null
+++ b/lti_consumer/tests/unit/plugin/test_views_lti_ags.py
@@ -0,0 +1,276 @@
+"""
+Tests for LTI Advantage Assignments and Grades Service views.
+"""
+import json
+from mock import patch, PropertyMock
+
+from Cryptodome.PublicKey import RSA
+import ddt
+from django.urls import reverse
+from jwkest.jwk import RSAKey
+from rest_framework.test import APITransactionTestCase
+
+
+from lti_consumer.lti_xblock import LtiConsumerXBlock
+from lti_consumer.models import LtiConfiguration, LtiAgsLineItem
+from lti_consumer.tests.unit.test_utils import make_xblock
+
+
+@ddt.ddt
+class TestLtiAgsLineItemViewSet(APITransactionTestCase):
+    """
+    Test `LtiAgsLineItemViewset` method.
+    """
+    def setUp(self):
+        super(TestLtiAgsLineItemViewSet, self).setUp()
+
+        # Create custom LTI Block
+        self.rsa_key_id = "1"
+        rsa_key = RSA.generate(2048)
+        self.key = RSAKey(
+            key=rsa_key,
+            kid=self.rsa_key_id
+        )
+        self.public_key = rsa_key.publickey().export_key()
+
+        self.xblock_attributes = {
+            'lti_version': 'lti_1p3',
+            'lti_1p3_launch_url': 'http://tool.example/launch',
+            'lti_1p3_oidc_url': 'http://tool.example/oidc',
+            # We need to set the values below because they are not automatically
+            # generated until the user selects `lti_version == 'lti_1p3'` on the
+            # Studio configuration view.
+            'lti_1p3_client_id': self.rsa_key_id,
+            'lti_1p3_block_key': rsa_key.export_key('PEM'),
+            # Intentionally using the same key for tool key to
+            # allow using signing methods and make testing easier.
+            'lti_1p3_tool_public_key': self.public_key,
+        }
+        self.xblock = make_xblock('lti_consumer', LtiConsumerXBlock, self.xblock_attributes)
+
+        # Set dummy location so that UsageKey lookup is valid
+        self.xblock.location = 'block-v1:course+test+2020+type@problem+block@test'
+
+        # Create configuration
+        self.lti_config = LtiConfiguration.objects.create(
+            location=str(self.xblock.location),
+            version=LtiConfiguration.LTI_1P3
+        )
+        # Preload XBlock to avoid calls to modulestore
+        self.lti_config.block = self.xblock
+
+        # Patch internal method to avoid calls to modulestore
+        patcher = patch(
+            'lti_consumer.models.LtiConfiguration.block',
+            new_callable=PropertyMock,
+            return_value=self.xblock
+        )
+        self.addCleanup(patcher.stop)
+        self._lti_block_patch = patcher.start()
+
+        # LineItem endpoint
+        self.lineitem_endpoint = reverse(
+            'lti_consumer:lti-ags-view-list',
+            kwargs={
+                "lti_config_id": self.lti_config.id
+            }
+        )
+
+    def _set_lti_token(self, scopes=None):
+        """
+        Generates and sets a LTI Auth token in the request client.
+        """
+        if not scopes:
+            scopes = ''
+
+        consumer = self.lti_config.get_lti_consumer()
+        token = consumer.key_handler.encode_and_sign({
+            "iss": "https://example.com",
+            "scopes": scopes,
+        })
+        # pylint: disable=no-member
+        self.client.credentials(
+            HTTP_AUTHORIZATION="Bearer {}".format(token)
+        )
+
+    def test_lti_ags_view_no_token(self):
+        """
+        Test the LTI AGS list view when there's no token.
+        """
+        response = self.client.get(self.lineitem_endpoint)
+        self.assertEqual(response.status_code, 403)
+
+    @ddt.data("Bearer invalid-token", "test", "Token with more items")
+    def test_lti_ags_view_invalid_token(self, authorization):
+        """
+        Test the LTI AGS list view when there's an invalid token.
+        """
+        self.client.credentials(HTTP_AUTHORIZATION=authorization)  # pylint: disable=no-member
+        response = self.client.get(self.lineitem_endpoint)
+
+        self.assertEqual(response.status_code, 403)
+
+    def test_lti_ags_token_missing_scopes(self):
+        """
+        Test the LTI AGS list view when there's a valid token without valid scopes.
+        """
+        self._set_lti_token()
+        response = self.client.get(self.lineitem_endpoint)
+        self.assertEqual(response.status_code, 403)
+
+    @ddt.data(
+        'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly',
+        'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem'
+    )
+    def test_lti_ags_list_permissions(self, scopes):
+        """
+        Test the LTI AGS list view when there's token valid scopes.
+        """
+        self._set_lti_token(scopes)
+        # Test with no LineItems
+        response = self.client.get(self.lineitem_endpoint)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.data, [])
+
+    def test_lti_ags_list(self):
+        """
+        Test the LTI AGS list.
+        """
+        self._set_lti_token('https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly')
+
+        # Create LineItem
+        line_item = LtiAgsLineItem.objects.create(
+            lti_configuration=self.lti_config,
+            resource_id="test",
+            resource_link_id=self.xblock.location,
+            label="test label",
+            score_maximum=100
+        )
+
+        # Retrieve & check
+        response = self.client.get(self.lineitem_endpoint)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response['content-type'], 'application/vnd.ims.lis.v2.lineitemcontainer+json')
+        self.assertEqual(
+            response.data,
+            [
+                {
+                    'id': 'http://testserver/lti_consumer/v1/lti/{}/lti-ags/{}'.format(
+                        self.lti_config.id,
+                        line_item.id
+                    ),
+                    'resourceId': 'test',
+                    'scoreMaximum': 100,
+                    'label': 'test label',
+                    'tag': '',
+                    'resourceLinkId': self.xblock.location,
+                    'startDateTime': None,
+                    'endDateTime': None,
+                }
+            ]
+        )
+
+    def test_lti_ags_retrieve(self):
+        """
+        Test the LTI AGS retrieve endpoint.
+        """
+        self._set_lti_token('https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly')
+
+        # Create LineItem
+        line_item = LtiAgsLineItem.objects.create(
+            lti_configuration=self.lti_config,
+            resource_id="test",
+            resource_link_id=self.xblock.location,
+            label="test label",
+            score_maximum=100
+        )
+
+        # Retrieve & check
+        lineitem_detail_url = reverse(
+            'lti_consumer:lti-ags-view-detail',
+            kwargs={
+                "lti_config_id": self.lti_config.id,
+                "pk": line_item.id
+            }
+        )
+        response = self.client.get(lineitem_detail_url)
+
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(
+            response.data,
+            {
+                'id': 'http://testserver/lti_consumer/v1/lti/{}/lti-ags/{}'.format(
+                    self.lti_config.id,
+                    line_item.id
+                ),
+                'resourceId': 'test',
+                'scoreMaximum': 100,
+                'label': 'test label',
+                'tag': '',
+                'resourceLinkId': self.xblock.location,
+                'startDateTime': None,
+                'endDateTime': None,
+            }
+        )
+
+    def test_create_lineitem(self):
+        """
+        Test the LTI AGS LineItem Creation.
+        """
+        self._set_lti_token('https://purl.imsglobal.org/spec/lti-ags/scope/lineitem')
+
+        # Create LineItem
+        response = self.client.post(
+            self.lineitem_endpoint,
+            data=json.dumps({
+                'resourceId': 'test',
+                'scoreMaximum': 100,
+                'label': 'test',
+                'tag': 'score',
+                'resourceLinkId': self.xblock.location,
+            }),
+            content_type="application/vnd.ims.lis.v2.lineitem+json",
+        )
+
+        self.assertEqual(response.status_code, 201)
+        self.assertEqual(
+            response.data,
+            {
+                'id': 'http://testserver/lti_consumer/v1/lti/1/lti-ags/1',
+                'resourceId': 'test',
+                'scoreMaximum': 100,
+                'label': 'test',
+                'tag': 'score',
+                'resourceLinkId': self.xblock.location,
+                'startDateTime': None,
+                'endDateTime': None,
+            }
+        )
+        self.assertEqual(LtiAgsLineItem.objects.all().count(), 1)
+        line_item = LtiAgsLineItem.objects.get()
+        self.assertEqual(line_item.resource_id, 'test')
+        self.assertEqual(line_item.score_maximum, 100)
+        self.assertEqual(line_item.label, 'test')
+        self.assertEqual(line_item.tag, 'score')
+        self.assertEqual(str(line_item.resource_link_id), self.xblock.location)
+
+    def test_create_lineitem_invalid_resource_link_id(self):
+        """
+        Test the LTI AGS Lineitem creation when passing invalid resource link id.
+        """
+        self._set_lti_token('https://purl.imsglobal.org/spec/lti-ags/scope/lineitem')
+
+        # Create LineItem
+        response = self.client.post(
+            self.lineitem_endpoint,
+            data=json.dumps({
+                'resourceId': 'test',
+                'scoreMaximum': 100,
+                'label': 'test',
+                'tag': 'score',
+                'resourceLinkId': 'invalid-resource-link',
+            }),
+            content_type="application/vnd.ims.lis.v2.lineitem+json",
+        )
+
+        self.assertEqual(response.status_code, 400)
diff --git a/lti_consumer/tests/unit/test_lti_xblock.py b/lti_consumer/tests/unit/test_lti_xblock.py
index 69d9ce7b05b73cf38da1fe75f31317f033e61743..812ed7c2f9b54fa909bd64a51d25b34f2ec1a018 100644
--- a/lti_consumer/tests/unit/test_lti_xblock.py
+++ b/lti_consumer/tests/unit/test_lti_xblock.py
@@ -1193,14 +1193,6 @@ class TestLtiConsumer1p3XBlock(TestCase):
         # Set dummy location so that UsageKey lookup is valid
         self.xblock.location = 'block-v1:course+test+2020+type@problem+block@test'
 
-        # Patch settings calls to modulestore
-        self._settings_mock = patch(
-            'lti_consumer.utils.settings',
-            LMS_ROOT_URL="https://example.com"
-        )
-        self.addCleanup(self._settings_mock.stop)
-        self._settings_mock.start()
-
     def test_launch_request(self):
         """
         Test LTI 1.3 launch request
@@ -1376,14 +1368,6 @@ class TestLti1p3AccessTokenEndpoint(TestLtiConsumerXBlock):
         # Set dummy location so that UsageKey lookup is valid
         self.xblock.location = 'block-v1:course+test+2020+type@problem+block@test'
 
-        # Patch settings calls to modulestore
-        self._settings_mock = patch(
-            'lti_consumer.utils.settings',
-            LMS_ROOT_URL="https://example.com"
-        )
-        self.addCleanup(self._settings_mock.stop)
-        self._settings_mock.start()
-
     def test_access_token_endpoint_when_using_lti_1p1(self):
         """
         Test that the LTI 1.3 access token endpoind is unavailable when using 1.1.
diff --git a/lti_consumer/tests/unit/test_models.py b/lti_consumer/tests/unit/test_models.py
index fa74094ea4745fd7f30467b729a8f41b96abf745..e18076b6c8604d3b8ceb178402c678a805e3108c 100644
--- a/lti_consumer/tests/unit/test_models.py
+++ b/lti_consumer/tests/unit/test_models.py
@@ -8,7 +8,7 @@ from jwkest.jwk import RSAKey
 from mock import patch
 
 from lti_consumer.lti_xblock import LtiConsumerXBlock
-from lti_consumer.models import LtiConfiguration
+from lti_consumer.models import LtiAgsLineItem, LtiConfiguration
 from lti_consumer.tests.unit.test_utils import make_xblock
 
 
@@ -39,19 +39,12 @@ class TestLtiCofigurationModel(TestCase):
             'lti_1p3_block_key': rsa_key.export_key('PEM'),
             # Use same key for tool key to make testing easier
             'lti_1p3_tool_public_key': self.public_key,
+            'has_score': True,
         }
         self.xblock = make_xblock('lti_consumer', LtiConsumerXBlock, self.xblock_attributes)
         # Set dummy location so that UsageKey lookup is valid
         self.xblock.location = 'block-v1:course+test+2020+type@problem+block@test'
 
-        # Patch settings calls to modulestore
-        self._settings_mock = patch(
-            'lti_consumer.utils.settings',
-            LMS_ROOT_URL="https://example.com"
-        )
-        self.addCleanup(self._settings_mock.stop)
-        self._settings_mock.start()
-
         # Creates an LTI configuration objects for testing
         self.lti_1p1_config = LtiConfiguration.objects.create(
             location=str(self.xblock.location),
@@ -89,3 +82,54 @@ class TestLtiCofigurationModel(TestCase):
             str(lti_config),
             "[CONFIG_ON_XBLOCK] lti_1p3 - {}".format(dummy_location)
         )
+
+    def test_lti_consumer_ags_enabled(self):
+        """
+        Check if LTI AGS is properly included when block is graded.
+        """
+        self.lti_1p3_config.block = self.xblock
+
+        # Get LTI 1.3 consumer
+        consumer = self.lti_1p3_config.get_lti_consumer()
+
+        # Check that LTI claim was included in extra claims
+        self.assertEqual(
+            consumer.extra_claims,
+            {
+                'https://purl.imsglobal.org/spec/lti-ags/claim/endpoint':
+                {
+                    'scope': [
+                        'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem',
+                        'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly',
+                        'https://purl.imsglobal.org/spec/lti-ags/scope/score',
+                    ],
+                    'lineitems': 'https://example.com/api/lti_consumer/v1/lti/2/lti-ags'
+                }
+            }
+        )
+
+
+class TestLtiAgsLineItemModel(TestCase):
+    """
+    Unit tests for LtiAgsLineItem model methods.
+    """
+    def setUp(self):
+        super(TestLtiAgsLineItemModel, self).setUp()
+
+        self.dummy_location = 'block-v1:course+test+2020+type@problem+block@test'
+        self.lti_ags_model = LtiAgsLineItem.objects.create(
+            lti_configuration=None,
+            resource_id="test-id",
+            label="this-is-a-test",
+            resource_link_id=self.dummy_location,
+            score_maximum=100,
+        )
+
+    def test_repr(self):
+        """
+        Test String representation of model.
+        """
+        self.assertEqual(
+            str(self.lti_ags_model),
+            "block-v1:course+test+2020+type@problem+block@test - this-is-a-test"
+        )
diff --git a/lti_consumer/utils.py b/lti_consumer/utils.py
index b1b7153fb8e976fb26a71b792a90ab56a61c976d..b70ee93feabb1caa47e3ae42b691fdb90d83cd92 100644
--- a/lti_consumer/utils.py
+++ b/lti_consumer/utils.py
@@ -37,7 +37,7 @@ def get_lms_lti_keyset_link(location):
 
     :param location: the location of the block
     """
-    return u"{lms_base}/api/lti_consumer/v1/public_keysets/{location}".format(
+    return "{lms_base}/api/lti_consumer/v1/public_keysets/{location}".format(
         lms_base=get_lms_base(),
         location=str(location),
     )
@@ -49,7 +49,7 @@ def get_lms_lti_launch_link():
 
     :param location: the location of the block
     """
-    return u"{lms_base}/api/lti_consumer/v1/launch/".format(
+    return "{lms_base}/api/lti_consumer/v1/launch/".format(
         lms_base=get_lms_base(),
     )
 
@@ -60,7 +60,19 @@ def get_lms_lti_access_token_link(location):
 
     :param location: the location of the block
     """
-    return u"{lms_base}/api/lti_consumer/v1/token/{location}".format(
+    return "{lms_base}/api/lti_consumer/v1/token/{location}".format(
         lms_base=get_lms_base(),
         location=str(location),
     )
+
+
+def get_lti_ags_lineitems_url(lti_config_id):
+    """
+    Return the LTI AGS endpoint
+
+    :param lti_config_id: LTI configuration id
+    """
+    return "{lms_base}/api/lti_consumer/v1/lti/{lti_config_id}/lti-ags".format(
+        lms_base=get_lms_base(),
+        lti_config_id=str(lti_config_id),
+    )
diff --git a/requirements/base.in b/requirements/base.in
index f317ec2a4e039ca119411e8d1b7e59945f0586a8..7e08e14b9f9a6b5a72aca8dc4bacd42d99ed0501 100644
--- a/requirements/base.in
+++ b/requirements/base.in
@@ -11,4 +11,5 @@ XBlock
 xblock-utils
 pycryptodomex
 pyjwkest
-edx-opaque-keys[django]
\ No newline at end of file
+edx-opaque-keys[django]
+django-filter
\ No newline at end of file
diff --git a/requirements/base.txt b/requirements/base.txt
index 9d455985fb796b41ba09d127bfae29d0fdc9d8ce..a90db9864fc50f7af08b5afbda8b6fc24aaf0cae 100644
--- a/requirements/base.txt
+++ b/requirements/base.txt
@@ -5,10 +5,11 @@
 #    make upgrade
 #
 appdirs==1.4.4            # via fs
-bleach==3.1.5             # via -r requirements/base.in
+bleach==3.2.1             # via -r requirements/base.in
 certifi==2020.6.20        # via requests
 chardet==3.0.4            # via requests
-django==2.2.16            # via -c requirements/constraints.txt, -r requirements/base.in, edx-opaque-keys
+django-filter==2.4.0      # via -r requirements/base.in
+django==2.2.16            # via -c requirements/constraints.txt, -r requirements/base.in, django-filter, edx-opaque-keys
 edx-opaque-keys[django]==2.1.1  # via -r requirements/base.in
 fs==2.4.11                # via xblock
 future==0.18.2            # via pyjwkest
@@ -32,7 +33,6 @@ simplejson==3.17.2        # via xblock-utils
 six==1.15.0               # via bleach, edx-opaque-keys, fs, packaging, pyjwkest, python-dateutil, stevedore, xblock
 sqlparse==0.3.1           # via django
 stevedore==1.32.0         # via -c requirements/constraints.txt, edx-opaque-keys
-typing==3.7.4.3           # via fs
 urllib3==1.25.10          # via requests
 web-fragments==0.3.2      # via xblock, xblock-utils
 webencodings==0.5.1       # via bleach
diff --git a/requirements/django.txt b/requirements/django.txt
index 95341a8d9b560bfc8d3e51e40fe1010b021c1885..2d9bf95f4d7288ed30f8ad2349020ae00a5821e3 100644
--- a/requirements/django.txt
+++ b/requirements/django.txt
@@ -1 +1 @@
-django==2.2.16            # via -c requirements/constraints.txt, -r requirements/base.txt, django-pyfs, edx-opaque-keys, xblock-sdk
+django==2.2.16            # via -c requirements/constraints.txt, -r requirements/base.txt, django-filter, django-pyfs, edx-opaque-keys, xblock-sdk
diff --git a/requirements/test.txt b/requirements/test.txt
index 25a140ccdd9b6640b2e760bb757dea01cd62bac1..5d5deafe8acd07d591be500196e92c3c741e413b 100644
--- a/requirements/test.txt
+++ b/requirements/test.txt
@@ -6,9 +6,9 @@
 #
 appdirs==1.4.4            # via -r requirements/base.txt, fs
 astroid==2.3.3            # via pylint, pylint-celery
-bleach==3.1.5             # via -r requirements/base.txt
-boto3==1.14.62            # via fs-s3fs
-botocore==1.17.62         # via boto3, s3transfer
+bleach==3.2.1             # via -r requirements/base.txt
+boto3==1.15.7             # via fs-s3fs
+botocore==1.18.7          # via boto3, s3transfer
 certifi==2020.6.20        # via -r requirements/base.txt, requests
 chardet==3.0.4            # via -r requirements/base.txt, requests
 click-log==0.3.2          # via edx-lint
@@ -16,10 +16,10 @@ click==7.1.2              # via click-log, edx-lint
 coverage==5.3             # via coveralls
 coveralls==2.1.2          # via -r requirements/test.in
 ddt==1.4.1                # via -r requirements/test.in
+django-filter==2.4.0      # via -r requirements/base.txt
 django-pyfs==2.2          # via -r requirements/test.in
 djangorestframework==3.9.4  # via -c requirements/constraints.txt, -r requirements/test.in
 docopt==0.6.2             # via coveralls
-docutils==0.15.2          # via botocore
 edx-lint==1.5.2           # via -r requirements/test.in
 edx-opaque-keys[django]==2.1.1  # via -r requirements/base.txt
 fs-s3fs==1.1.1            # via django-pyfs
diff --git a/requirements/travis.txt b/requirements/travis.txt
index f8806d21b96a3b8c505b822cc23003acb482444e..e064147df24aaffa0cee023f0eaf24326fa9bc04 100644
--- a/requirements/travis.txt
+++ b/requirements/travis.txt
@@ -6,9 +6,9 @@
 #
 appdirs==1.4.4            # via -r requirements/test.txt, -r requirements/tox.txt, fs, virtualenv
 astroid==2.3.3            # via -r requirements/test.txt, pylint, pylint-celery
-bleach==3.1.5             # via -r requirements/test.txt
-boto3==1.14.62            # via -r requirements/test.txt, fs-s3fs
-botocore==1.17.62         # via -r requirements/test.txt, boto3, s3transfer
+bleach==3.2.1             # via -r requirements/test.txt
+boto3==1.15.7             # via -r requirements/test.txt, fs-s3fs
+botocore==1.18.7          # via -r requirements/test.txt, boto3, s3transfer
 certifi==2020.6.20        # via -r requirements/test.txt, requests
 chardet==3.0.4            # via -r requirements/test.txt, requests
 click-log==0.3.2          # via -r requirements/test.txt, edx-lint
@@ -17,11 +17,11 @@ coverage==5.3             # via -r requirements/test.txt, coveralls
 coveralls==2.1.2          # via -r requirements/test.txt
 ddt==1.4.1                # via -r requirements/test.txt
 distlib==0.3.1            # via -r requirements/tox.txt, virtualenv
+django-filter==2.4.0      # via -r requirements/test.txt
 django-pyfs==2.2          # via -r requirements/test.txt
-django==2.2.16            # via -c requirements/constraints.txt, -r requirements/test.txt, django-pyfs, edx-opaque-keys, xblock-sdk
+django==2.2.16            # via -c requirements/constraints.txt, -r requirements/test.txt, django-filter, django-pyfs, edx-opaque-keys, xblock-sdk
 djangorestframework==3.9.4  # via -c requirements/constraints.txt, -r requirements/test.txt
 docopt==0.6.2             # via -r requirements/test.txt, coveralls
-docutils==0.15.2          # via -r requirements/test.txt, botocore
 edx-lint==1.5.2           # via -r requirements/test.txt
 edx-opaque-keys[django]==2.1.1  # via -r requirements/test.txt
 filelock==3.0.12          # via -r requirements/tox.txt, tox, virtualenv
diff --git a/test_settings.py b/test_settings.py
index e6e2f7fa10b6833d1007b576207f09dcc60e54bf..47f7852e6ba867028f2dbe5c928d8190244170dd 100644
--- a/test_settings.py
+++ b/test_settings.py
@@ -9,3 +9,6 @@ USAGE_ID_PATTERN = r'(?P<usage_id>(?:i4x://?[^/]+/[^/]+/[^/]+/[^@]+(?:@[^/]+)?)|
 
 # Keep settings, use different ROOT_URLCONF
 ROOT_URLCONF = 'test_urls'
+
+# LMS Urls - for LTI 1.3 testing
+LMS_ROOT_URL = "https://example.com"
\ No newline at end of file
diff --git a/test_urls.py b/test_urls.py
index 66f5f749dd579ff0866ae9391bbee5191546f3b4..b250558f9d91a01ed36ab599a33aa1b64bea5a33 100644
--- a/test_urls.py
+++ b/test_urls.py
@@ -5,5 +5,5 @@ from django.conf.urls import include, re_path
 
 urlpatterns = [
     re_path(r'^', include('workbench.urls')),
-    re_path(r'^', include('lti_consumer.plugin.urls')),
+    re_path(r'^', include('lti_consumer.plugin.urls', namespace='lti_consumer')),
 ]