Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add course to launch gate and api #26

Merged
merged 18 commits into from
Aug 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
## Unreleased

## 2.3.0
### Adds
- [#25](https://github.com/ibleducation/ibl-edx-lti-1p3-provider-app/issues/25): Adds `allowed_courses` to `LaunchGate`
- [#21](https://github.com/ibleducation/ibl-edx-lti-1p3-provider-app/issues/21): Adds API to manage `LtiToolKey`'s at a tenant level
- [#22](https://github.com/ibleducation/ibl-edx-lti-1p3-provider-app/issues/22): Adds API to manage `LtiTool`'s at a tenant level

Expand Down
7 changes: 6 additions & 1 deletion src/lti_1p3_provider/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ class LtiGradedResourceAdmin(admin.ModelAdmin):


@admin.register(models.LtiToolOrg)
class LtiTooLOrgAdmin(admin.ModelAdmin):
class LtiToolOrgAdmin(admin.ModelAdmin):
list_display = ("tool_name", "tool_issuer", "tool_client_id", "edx_org_name")

def tool_name(self, obj) -> str:
Expand All @@ -67,3 +67,8 @@ def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == "tool":
formfield.label_from_instance = lambda obj: f"{obj.title} ({obj.client_id})"
return formfield


@admin.register(models.LtiKeyOrg)
class LtiKeyOrgAdmin(admin.ModelAdmin):
list_display = ("key", "org")
151 changes: 144 additions & 7 deletions src/lti_1p3_provider/api/serializers.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,44 @@
from __future__ import annotations

import json

from django.db import IntegrityError
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey, UsageKey
from openedx.core.lib.api.serializers import CourseKeyField, UsageKeyField
from organizations.models import Organization
from pylti1p3.contrib.django.lti1p3_tool_config.models import LtiTool, LtiToolKey
from rest_framework import serializers

from ..models import LtiKeyOrg, LtiToolOrg
from ..models import LaunchGate, LtiKeyOrg, LtiToolOrg
from . import ssl_services


class StringListField(serializers.ListField):
child = serializers.CharField()


class TextBackedListField(StringListField):
"""A ListField backed by a Char-Type field in the db

- Writes as a JSON String
- Reads object from a JSON string
"""

def to_representation(self, data):
if isinstance(data, str):
try:
data = json.loads(data)
except ValueError:
data = []
return super().to_representation(data)

def to_internal_value(self, data):
if data:
return json.dumps(data)
return "[]"


class LtiToolKeySerializer(serializers.ModelSerializer):
class Meta:
model = LtiToolKey
Expand Down Expand Up @@ -41,7 +67,10 @@ def to_representation(self, instance):
def update(self, instance, validated_data):
name = validated_data["name"]
validated_data["name"] = f"{self.context['org_short_name']}-{name}"
return super().update(instance, validated_data)
try:
return super().update(instance, validated_data)
except IntegrityError:
raise serializers.ValidationError(f"Key name: '{name}' already exists")

def create(self, validated_data):
"""Autogenerate private/public key pairs"""
Expand All @@ -59,16 +88,100 @@ def create(self, validated_data):
tool_key = LtiToolKey.objects.create(**validated_data)
LtiKeyOrg.objects.create(key=tool_key, org=lti_org)
except IntegrityError:
raise serializers.ValidationError(f"Tool name: '{name}' already exists")
raise serializers.ValidationError(f"Key name: '{name}' already exists")
return tool_key


class LaunchGateSerializer(serializers.ModelSerializer):
class Meta:
model = LaunchGate
fields = ["allowed_keys", "allowed_courses", "allow_all_within_org"]

allowed_keys = StringListField(
allow_empty=True,
default=lambda: [],
)
allowed_courses = StringListField(
allow_empty=True,
default=lambda: [],
)
allow_all_within_org = serializers.BooleanField(
default=False,
help_text="If True, a target_link_uri will work with any content within this org",
)

def validate(self, attrs):
"""Ensure at least one of allow* is set"""
if not (
attrs["allowed_keys"]
or attrs["allowed_courses"]
or attrs["allow_all_within_org"]
):
raise serializers.ValidationError(
"Set either allow_all_within_org or one or more of allowed_courses/allowed_keys"
)
return attrs

def to_representation(self, instance):
rep = super().to_representation(instance)
rep["allow_all_within_org"] = instance.allowed_orgs == [
self.context["org_short_name"]
]
return rep

def validate_allowed_courses(self, value):
for key in value:
try:
key = CourseKey.from_string(key)
org_short_name = self.context["org_short_name"]
if key.org != org_short_name:
raise serializers.ValidationError(
f"Course Key must be within org: {org_short_name}"
)
except InvalidKeyError:
raise serializers.ValidationError(
"Invalid Course Key. Format is: course-v1:<org>+<course>+<run>"
)
return value

def validate_allowed_keys(self, value):
for key in value:
try:
key = UsageKey.from_string(key)
org_short_name = self.context["org_short_name"]
if key.course_key.org != org_short_name:
raise serializers.ValidationError(
f"Usage Key must be within org: {org_short_name}"
)
except InvalidKeyError:
raise serializers.ValidationError(
"Invalid Usage Key. Format is: "
"block-v1:<org>+<course>+<run>+type@<block_type>+block@<hex_uuid>"
)
return value


class LtiToolSerializer(serializers.ModelSerializer):
class Meta:
model = LtiTool
fields = "__all__"

deployment_ids = StringListField()
fields = [
"id",
"title",
"issuer",
"is_active",
"client_id",
"auth_login_url",
"auth_token_url",
"auth_audience",
"key_set_url",
"key_set",
"tool_key",
"deployment_ids",
"launch_gate",
]

deployment_ids = TextBackedListField()
launch_gate = LaunchGateSerializer()

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
Expand All @@ -79,6 +192,12 @@ def __init__(self, *args, **kwargs):

def validate(self, attrs):
short_name = self.context["org_short_name"]
# Since this endpoint is for an org, allowed_orgs must be [] or their org only
allow_all_within_org = attrs["launch_gate"].pop("allow_all_within_org")
attrs["launch_gate"]["allowed_orgs"] = (
[short_name] if allow_all_within_org else []
)

try:
# Since we're validating it we may as well store it
attrs["org"] = Organization.objects.get(short_name=short_name)
Expand All @@ -94,8 +213,26 @@ def validate(self, attrs):

return attrs

def update(self, instance, validated_data):
"""Update object and launch gate, creating launch gate if necessary"""
# Update LtiTool object
launch_gate_data = validated_data.pop("launch_gate")
for attr, value in validated_data.items():
setattr(instance, attr, value)
instance.save()

# Update LaunchGate
launch_gate = instance.launch_gate
for attr, value in launch_gate_data.items():
setattr(launch_gate, attr, value)
launch_gate.save()

return instance

def create(self, validated_data):
lti_org = validated_data.pop("org")
tool = super().create(validated_data)
launch_gate_data = validated_data.pop("launch_gate")
tool = LtiTool.objects.create(**validated_data)
LtiToolOrg.objects.create(tool=tool, org=lti_org)
LaunchGate.objects.create(tool=tool, **launch_gate_data)
return tool
Loading