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.
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
File without changes
@@ -0,0 +1,5 @@
1
+ # Install frontend-plugin-framework for MFE Plugin Slots support
2
+ RUN npm install @openedx/frontend-plugin-framework@^1.7.0 --save
3
+
4
+ # Copy the prebuilt Nex Chat widget plugin into the MFE app root
5
+ COPY nex-chat-plugin.js /openedx/app/nex-chat-plugin.js
@@ -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
@@ -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,4 @@
1
+ from lms.urls import urlpatterns as lms_urlpatterns
2
+ from nex_chat_lms import urlpatterns as nex_chat_urlpatterns
3
+
4
+ urlpatterns = [*lms_urlpatterns, *nex_chat_urlpatterns]
@@ -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
+ ]