openedx-next-tutor 1.0.0__py3-none-any.whl
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.
- nex_chat_tutor/__about__.py +1 -0
- nex_chat_tutor/__init__.py +0 -0
- nex_chat_tutor/patches/mfe-dockerfile-post-npm-install +5 -0
- nex_chat_tutor/patches/mfe-env-config-buildtime-definitions +38 -0
- nex_chat_tutor/patches/mfe-env-config-buildtime-imports +1 -0
- nex_chat_tutor/patches/openedx-dockerfile-post-git-checkout +8 -0
- nex_chat_tutor/patches/openedx-lms-common-settings +9 -0
- nex_chat_tutor/plugin.py +67 -0
- nex_chat_tutor/templates/nex-chat-dashboard-loader.js +20 -0
- nex_chat_tutor/templates/nex-chat-dashboard.html +11 -0
- nex_chat_tutor/templates/nex-chat-dashboard.patch +11 -0
- nex_chat_tutor/templates/nex-chat-lms-urls.py +4 -0
- nex_chat_tutor/templates/nex-chat-lms.py +158 -0
- nex_chat_tutor/templates/nex-chat-plugin.js +4527 -0
- openedx_next_tutor-1.0.0.dist-info/METADATA +9 -0
- openedx_next_tutor-1.0.0.dist-info/RECORD +19 -0
- openedx_next_tutor-1.0.0.dist-info/WHEEL +5 -0
- openedx_next_tutor-1.0.0.dist-info/entry_points.txt +2 -0
- openedx_next_tutor-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
|
File without changes
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Nex Chat widget mounted through the Frontend Plugin Framework
|
|
2
|
+
const NexChatWidgetInjector = () => {
|
|
3
|
+
React.useEffect(() => {
|
|
4
|
+
let cleanup;
|
|
5
|
+
let disposed = false;
|
|
6
|
+
|
|
7
|
+
if (!{{ NEX_CHAT_ENABLED | tojson }}) {
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
window.NEX_CHAT_API_BASE_URL = {{ NEX_CHAT_API_BASE_URL | tojson }};
|
|
12
|
+
window.NEX_CHAT_WS_URL = {{ NEX_CHAT_WS_URL | tojson }};
|
|
13
|
+
window.NEX_CHAT_ASSERTION_URL = {{ NEX_CHAT_ASSERTION_URL | tojson }};
|
|
14
|
+
|
|
15
|
+
import('./nex-chat-plugin.js')
|
|
16
|
+
.then((mod) => {
|
|
17
|
+
if (disposed) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (typeof mod.default !== 'function') {
|
|
21
|
+
throw new TypeError('Nex Chat bundle does not export a mount function');
|
|
22
|
+
}
|
|
23
|
+
cleanup = mod.default();
|
|
24
|
+
})
|
|
25
|
+
.catch((error) => {
|
|
26
|
+
console.error('NexChatWidget: failed to load widget bundle', error);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
return () => {
|
|
30
|
+
disposed = true;
|
|
31
|
+
if (typeof cleanup === 'function') {
|
|
32
|
+
cleanup();
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}, []);
|
|
36
|
+
|
|
37
|
+
return null;
|
|
38
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import React from 'react';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Install the Nex Chat bundle and its legacy learner-dashboard mount.
|
|
2
|
+
COPY nex-chat-plugin.js /openedx/edx-platform/lms/static/nex-chat-plugin.js
|
|
3
|
+
COPY nex-chat-dashboard-loader.js /openedx/edx-platform/lms/static/nex-chat-dashboard-loader.js
|
|
4
|
+
COPY nex-chat-dashboard.html /openedx/edx-platform/lms/templates/nex-chat-dashboard.html
|
|
5
|
+
COPY nex-chat-lms.py /openedx/edx-platform/nex_chat_lms.py
|
|
6
|
+
COPY nex-chat-lms-urls.py /openedx/edx-platform/nex_chat_lms_urls.py
|
|
7
|
+
COPY nex-chat-dashboard.patch /tmp/nex-chat-dashboard.patch
|
|
8
|
+
RUN git apply /tmp/nex-chat-dashboard.patch
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
ROOT_URLCONF = "nex_chat_lms_urls"
|
|
2
|
+
NEX_CHAT_ENABLED = {{ "True" if NEX_CHAT_ENABLED else "False" }}
|
|
3
|
+
NEX_CHAT_ASSERTION_ISSUER = {{ NEX_CHAT_ASSERTION_ISSUER | tojson }}
|
|
4
|
+
NEX_CHAT_ASSERTION_AUDIENCE = {{ NEX_CHAT_ASSERTION_AUDIENCE | tojson }}
|
|
5
|
+
NEX_CHAT_ASSERTION_KEY_ID = {{ NEX_CHAT_ASSERTION_KEY_ID | tojson }}
|
|
6
|
+
NEX_CHAT_ASSERTION_PRIVATE_KEY = {{ NEX_CHAT_ASSERTION_PRIVATE_KEY | tojson }}
|
|
7
|
+
NEX_CHAT_ASSERTION_JWKS = {{ NEX_CHAT_ASSERTION_JWKS | tojson }}
|
|
8
|
+
NEX_CHAT_ASSERTION_ALLOWED_ORIGINS = {{ NEX_CHAT_ASSERTION_ALLOWED_ORIGINS | tojson }}
|
|
9
|
+
CSRF_TRUSTED_ORIGINS += NEX_CHAT_ASSERTION_ALLOWED_ORIGINS
|
nex_chat_tutor/plugin.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from glob import glob
|
|
3
|
+
|
|
4
|
+
from tutor import hooks
|
|
5
|
+
from tutormfe.hooks import PLUGIN_SLOTS
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
PLUGIN_DIR = os.path.dirname(__file__)
|
|
9
|
+
|
|
10
|
+
hooks.Filters.CONFIG_DEFAULTS.add_items(
|
|
11
|
+
[
|
|
12
|
+
("NEX_CHAT_ENABLED", True),
|
|
13
|
+
("NEX_CHAT_API_BASE_URL", "http://local.openedx.io:3010/api/v1"),
|
|
14
|
+
("NEX_CHAT_WS_URL", "ws://local.openedx.io:3010/api/v1/ws"),
|
|
15
|
+
("NEX_CHAT_ASSERTION_URL", "http://local.openedx.io:8000/nex-chat/assertion"),
|
|
16
|
+
("NEX_CHAT_ASSERTION_ISSUER", "http://local.openedx.io:8000"),
|
|
17
|
+
("NEX_CHAT_ASSERTION_AUDIENCE", "nex-chat"),
|
|
18
|
+
("NEX_CHAT_ASSERTION_KEY_ID", "nex-chat"),
|
|
19
|
+
("NEX_CHAT_ASSERTION_PRIVATE_KEY", ""),
|
|
20
|
+
("NEX_CHAT_ASSERTION_JWKS", "{\"keys\": []}"),
|
|
21
|
+
("NEX_CHAT_ASSERTION_ALLOWED_ORIGINS", ["http://apps.local.openedx.io:2000"]),
|
|
22
|
+
]
|
|
23
|
+
)
|
|
24
|
+
hooks.Filters.ENV_TEMPLATE_TARGETS.add_items(
|
|
25
|
+
[
|
|
26
|
+
("nex-chat-plugin.js", "plugins/mfe/build/mfe"),
|
|
27
|
+
("nex-chat-plugin.js", "build/openedx"),
|
|
28
|
+
("nex-chat-dashboard.html", "build/openedx"),
|
|
29
|
+
("nex-chat-dashboard-loader.js", "build/openedx"),
|
|
30
|
+
("nex-chat-dashboard.patch", "build/openedx"),
|
|
31
|
+
("nex-chat-lms.py", "build/openedx"),
|
|
32
|
+
("nex-chat-lms-urls.py", "build/openedx"),
|
|
33
|
+
]
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
NEX_CHAT_SLOT_CONFIG = """
|
|
38
|
+
{
|
|
39
|
+
op: PLUGIN_OPERATIONS.Insert,
|
|
40
|
+
widget: {
|
|
41
|
+
id: 'nexChatWidget',
|
|
42
|
+
type: DIRECT_PLUGIN,
|
|
43
|
+
priority: 99,
|
|
44
|
+
RenderWidget: NexChatWidgetInjector,
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
PLUGIN_SLOTS.add_items(
|
|
50
|
+
[
|
|
51
|
+
("learner-dashboard", "footer_slot", NEX_CHAT_SLOT_CONFIG),
|
|
52
|
+
("learning", "footer_slot", NEX_CHAT_SLOT_CONFIG),
|
|
53
|
+
]
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
@hooks.Filters.ENV_TEMPLATE_ROOTS.add()
|
|
57
|
+
def _add_template_roots(roots):
|
|
58
|
+
roots.append(os.path.join(PLUGIN_DIR, "templates"))
|
|
59
|
+
return roots
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
for path in glob(os.path.join(PLUGIN_DIR, "patches", "*")):
|
|
65
|
+
with open(path, encoding="utf-8") as patch_file:
|
|
66
|
+
patch_name = os.path.basename(path)
|
|
67
|
+
hooks.Filters.ENV_PATCHES.add_item((patch_name, patch_file.read()))
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const configuration = document.getElementById('nex-chat-config');
|
|
2
|
+
|
|
3
|
+
if (!configuration) {
|
|
4
|
+
throw new Error('Nex Chat dashboard configuration is unavailable');
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
window.NEX_CHAT_API_BASE_URL = configuration.dataset.apiBaseUrl;
|
|
8
|
+
window.NEX_CHAT_WS_URL = configuration.dataset.wsUrl;
|
|
9
|
+
window.NEX_CHAT_ASSERTION_URL = configuration.dataset.assertionUrl;
|
|
10
|
+
|
|
11
|
+
import('./nex-chat-plugin.js')
|
|
12
|
+
.then((module) => {
|
|
13
|
+
if (typeof module.default !== 'function') {
|
|
14
|
+
throw new TypeError('Nex Chat bundle does not export a mount function');
|
|
15
|
+
}
|
|
16
|
+
module.default();
|
|
17
|
+
})
|
|
18
|
+
.catch((error) => {
|
|
19
|
+
console.error('NexChatWidget: failed to load widget bundle', error);
|
|
20
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{% if NEX_CHAT_ENABLED %}
|
|
2
|
+
<%namespace name="static" file="static_content.html"/>
|
|
3
|
+
<div
|
|
4
|
+
id="nex-chat-config"
|
|
5
|
+
hidden
|
|
6
|
+
data-api-base-url={{ NEX_CHAT_API_BASE_URL | tojson }}
|
|
7
|
+
data-ws-url={{ NEX_CHAT_WS_URL | tojson }}
|
|
8
|
+
data-assertion-url={{ NEX_CHAT_ASSERTION_URL | tojson }}
|
|
9
|
+
></div>
|
|
10
|
+
<script type="module" src="${static.url('nex-chat-dashboard-loader.js')}"></script>
|
|
11
|
+
{% endif %}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html
|
|
2
|
+
--- a/lms/templates/dashboard.html
|
|
3
|
+
+++ b/lms/templates/dashboard.html
|
|
4
|
+
@@ -42,6 +42,7 @@
|
|
5
|
+
<script src="${static.url('js/commerce/credit.js')}"></script>
|
|
6
|
+
<script type="text/javascript" src="${static.url('js/learner_dashboard/certificate_api.js')}"></script>
|
|
7
|
+
<%static:js group='dashboard'/>
|
|
8
|
+
+ <%include file="nex-chat-dashboard.html"/>
|
|
9
|
+
<script type="text/javascript">
|
|
10
|
+
$(document).ready(function() {
|
|
11
|
+
edx.dashboard.legacy.init({
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
import jwt
|
|
6
|
+
from django.conf import settings
|
|
7
|
+
from django.http import HttpResponseNotAllowed, JsonResponse
|
|
8
|
+
from django.http.request import split_domain_port
|
|
9
|
+
from django.middleware.csrf import get_token
|
|
10
|
+
from django.urls import path
|
|
11
|
+
from django.utils.cache import patch_vary_headers
|
|
12
|
+
from opaque_keys import InvalidKeyError
|
|
13
|
+
from opaque_keys.edx.keys import CourseKey
|
|
14
|
+
from common.djangoapps.student.roles import CourseStaffRole
|
|
15
|
+
from common.djangoapps.student.models import CourseEnrollment
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
MAX_REQUEST_BYTES = 4096
|
|
19
|
+
ASSERTION_LIFETIME_SECONDS = 300
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _allowed_origins():
|
|
23
|
+
configured = settings.NEX_CHAT_ASSERTION_ALLOWED_ORIGINS
|
|
24
|
+
if isinstance(configured, str):
|
|
25
|
+
try:
|
|
26
|
+
configured = json.loads(configured)
|
|
27
|
+
except json.JSONDecodeError:
|
|
28
|
+
return set()
|
|
29
|
+
if not isinstance(configured, list):
|
|
30
|
+
return set()
|
|
31
|
+
return {origin for origin in configured if isinstance(origin, str)}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _cors_response(response, request):
|
|
35
|
+
origin = request.headers.get("Origin")
|
|
36
|
+
if origin and origin in _allowed_origins():
|
|
37
|
+
response["Access-Control-Allow-Origin"] = origin
|
|
38
|
+
response["Access-Control-Allow-Credentials"] = "true"
|
|
39
|
+
response["Access-Control-Allow-Headers"] = "Content-Type, X-CSRFToken"
|
|
40
|
+
response["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
|
|
41
|
+
patch_vary_headers(response, ("Origin",))
|
|
42
|
+
return response
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _json_error(request, message, status):
|
|
46
|
+
return _cors_response(JsonResponse({"error": message}, status=status), request)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _request_course_id(request):
|
|
50
|
+
if len(request.body) > MAX_REQUEST_BYTES:
|
|
51
|
+
raise ValueError("request is too large")
|
|
52
|
+
payload = json.loads(request.body or b"{}")
|
|
53
|
+
if not isinstance(payload, dict) or set(payload) - {"course_id"}:
|
|
54
|
+
raise ValueError("request is invalid")
|
|
55
|
+
course_id = payload.get("course_id")
|
|
56
|
+
if course_id is not None and not isinstance(course_id, str):
|
|
57
|
+
raise ValueError("course ID is invalid")
|
|
58
|
+
return course_id
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _course_role(user, course_id):
|
|
62
|
+
if not course_id.startswith("course-v1:"):
|
|
63
|
+
raise ValueError("course ID is invalid")
|
|
64
|
+
try:
|
|
65
|
+
course_key = CourseKey.from_string(course_id)
|
|
66
|
+
except InvalidKeyError as exc:
|
|
67
|
+
raise ValueError("course ID is invalid") from exc
|
|
68
|
+
if str(course_key) != course_id:
|
|
69
|
+
raise ValueError("course ID is invalid")
|
|
70
|
+
|
|
71
|
+
is_staff = CourseStaffRole(course_key).has_user(user)
|
|
72
|
+
if not is_staff and not CourseEnrollment.is_enrolled(user, course_key):
|
|
73
|
+
raise PermissionError("user is not enrolled in this course")
|
|
74
|
+
return "staff" if is_staff else "student"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _assertion_configuration():
|
|
78
|
+
private_key = settings.NEX_CHAT_ASSERTION_PRIVATE_KEY
|
|
79
|
+
key_id = settings.NEX_CHAT_ASSERTION_KEY_ID
|
|
80
|
+
if not isinstance(private_key, str) or not private_key or not isinstance(key_id, str) or not key_id:
|
|
81
|
+
raise RuntimeError("Nex Chat assertion signing is not configured")
|
|
82
|
+
private_key = private_key.strip()
|
|
83
|
+
private_key = private_key.replace(
|
|
84
|
+
"-----BEGIN PRIVATE KEY-----",
|
|
85
|
+
"-----BEGIN PRIVATE KEY-----\n",
|
|
86
|
+
).replace(
|
|
87
|
+
"-----END PRIVATE KEY-----",
|
|
88
|
+
"\n-----END PRIVATE KEY-----",
|
|
89
|
+
)
|
|
90
|
+
return private_key, key_id
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def assertion(request):
|
|
94
|
+
if request.method == "OPTIONS":
|
|
95
|
+
return _cors_response(JsonResponse({}, status=204), request)
|
|
96
|
+
if request.method not in {"GET", "POST"}:
|
|
97
|
+
return HttpResponseNotAllowed(["GET", "POST", "OPTIONS"])
|
|
98
|
+
if not settings.NEX_CHAT_ENABLED:
|
|
99
|
+
return _json_error(request, "not found", 404)
|
|
100
|
+
if not request.user.is_authenticated:
|
|
101
|
+
return _json_error(request, "authentication required", 401)
|
|
102
|
+
if request.method == "GET":
|
|
103
|
+
return _cors_response(JsonResponse({"csrf_token": get_token(request)}), request)
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
course_id = _request_course_id(request)
|
|
107
|
+
role = _course_role(request.user, course_id) if course_id else (
|
|
108
|
+
"staff" if request.user.is_staff else "student"
|
|
109
|
+
)
|
|
110
|
+
private_key, key_id = _assertion_configuration()
|
|
111
|
+
except (ValueError, json.JSONDecodeError) as exc:
|
|
112
|
+
return _json_error(request, str(exc), 400)
|
|
113
|
+
except PermissionError as exc:
|
|
114
|
+
return _json_error(request, str(exc), 403)
|
|
115
|
+
except RuntimeError:
|
|
116
|
+
return _json_error(request, "chat assertion service is unavailable", 503)
|
|
117
|
+
|
|
118
|
+
now = int(time.time())
|
|
119
|
+
site, _ = split_domain_port(request.get_host())
|
|
120
|
+
claims = {
|
|
121
|
+
"iss": settings.NEX_CHAT_ASSERTION_ISSUER,
|
|
122
|
+
"aud": settings.NEX_CHAT_ASSERTION_AUDIENCE,
|
|
123
|
+
"sub": str(request.user.id),
|
|
124
|
+
"username": request.user.get_username(),
|
|
125
|
+
"email": request.user.email or None,
|
|
126
|
+
"roles": [role],
|
|
127
|
+
"site": site.lower(),
|
|
128
|
+
"course_id": course_id,
|
|
129
|
+
"iat": now,
|
|
130
|
+
"exp": now + ASSERTION_LIFETIME_SECONDS,
|
|
131
|
+
"jti": str(uuid.uuid4()),
|
|
132
|
+
}
|
|
133
|
+
signed_assertion = jwt.encode(
|
|
134
|
+
claims,
|
|
135
|
+
private_key,
|
|
136
|
+
algorithm="RS256",
|
|
137
|
+
headers={"kid": key_id, "typ": "JWT"},
|
|
138
|
+
)
|
|
139
|
+
return _cors_response(JsonResponse({"assertion": signed_assertion}), request)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def jwks(request):
|
|
143
|
+
if request.method != "GET":
|
|
144
|
+
return HttpResponseNotAllowed(["GET"])
|
|
145
|
+
try:
|
|
146
|
+
configured = settings.NEX_CHAT_ASSERTION_JWKS
|
|
147
|
+
value = json.loads(configured) if isinstance(configured, str) else configured
|
|
148
|
+
if not isinstance(value, dict) or not isinstance(value.get("keys"), list):
|
|
149
|
+
raise ValueError
|
|
150
|
+
except (ValueError, json.JSONDecodeError):
|
|
151
|
+
return JsonResponse({"error": "chat signing keys are unavailable"}, status=503)
|
|
152
|
+
return JsonResponse(value)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
urlpatterns = [
|
|
156
|
+
path("nex-chat/assertion", assertion, name="nex-chat-assertion"),
|
|
157
|
+
path(".well-known/nex-chat/jwks.json", jwks, name="nex-chat-jwks"),
|
|
158
|
+
]
|