fahd-edx-plugin 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.
fahd_edx/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Fahd AI assistant — Django App Plugin for Open edX."""
2
+
3
+ default_app_config = "fahd_edx.apps.FahdPluginConfig"
fahd_edx/apps.py ADDED
@@ -0,0 +1,53 @@
1
+ # ============================================================
2
+ # FILE: fahd_edx/apps.py
3
+ # PURPOSE: Django App Plugin registration for Open edX
4
+ #
5
+ # HOW THIS WORKS:
6
+ # Open edX discovers this via the lms.djangoapp / cms.djangoapp
7
+ # entry points in pyproject.toml. At startup, it reads plugin_app
8
+ # and auto-loads our settings (which register the middleware).
9
+ #
10
+ # No Tutor patches needed. No manual MIDDLEWARE.append().
11
+ # Just: pip install fahd-edx-plugin → restart → Fahd appears.
12
+ #
13
+ # PATTERN FROM:
14
+ # - github.com/openedx/sample-plugin (official reference)
15
+ # - github.com/mitodl/open-edx-plugins (MIT production)
16
+ # ============================================================
17
+
18
+ from django.apps import AppConfig
19
+
20
+ try:
21
+ from edx_django_utils.plugins.constants import PluginSettings
22
+ except ImportError:
23
+ # Fallback if edx-django-utils is not installed (e.g., local testing).
24
+ # These are just string constants — the actual values don't matter
25
+ # as long as they match what edx-django-utils expects.
26
+ class PluginSettings:
27
+ CONFIG = "settings_config"
28
+ RELATIVE_PATH = "relative_path"
29
+
30
+
31
+ class FahdPluginConfig(AppConfig):
32
+ """
33
+ Django App Plugin: Fahd AI assistant for Open edX.
34
+
35
+ Auto-discovered via lms.djangoapp / cms.djangoapp entry points.
36
+ Just pip install and restart — no Tutor patches needed.
37
+ """
38
+
39
+ name = "fahd_edx"
40
+ verbose_name = "Fahd AI Assistant"
41
+
42
+ plugin_app = {
43
+ PluginSettings.CONFIG: {
44
+ "lms.djangoapp": {
45
+ "common": {PluginSettings.RELATIVE_PATH: "settings.common"},
46
+ "production": {PluginSettings.RELATIVE_PATH: "settings.production"},
47
+ },
48
+ "cms.djangoapp": {
49
+ "common": {PluginSettings.RELATIVE_PATH: "settings.common"},
50
+ "production": {PluginSettings.RELATIVE_PATH: "settings.production"},
51
+ },
52
+ },
53
+ }
fahd_edx/middleware.py ADDED
@@ -0,0 +1,165 @@
1
+ # ============================================================
2
+ # FILE: fahd_edx/middleware.py
3
+ # PURPOSE: Inject Fahd embed.js into every legacy HTML page
4
+ #
5
+ # COVERS: Dashboard, Grades, Course About, Profile, Admin, etc.
6
+ # DOES NOT COVER: MFE React pages (Phase 2 — footer slot)
7
+ #
8
+ # HOW IT WORKS:
9
+ # 1. Django renders an HTML response
10
+ # 2. This middleware intercepts it
11
+ # 3. If user is authenticated and Fahd is configured:
12
+ # - Extract user identity (uid, role, course)
13
+ # - Generate HMAC-SHA256 token (same format as Moodle/BB plugins)
14
+ # - Inject <script src="embed.js" data-token="..."> before </head>
15
+ # 4. embed.js creates the floating Fahd button
16
+ #
17
+ # PERFORMANCE:
18
+ # - ~1ms overhead (string replacement, no network calls)
19
+ # - Script loads with `defer` (non-blocking)
20
+ # - If Fahd server is down, button shows grayed out (no page impact)
21
+ #
22
+ # TOKEN FORMAT (must match v2 core/auth.py and PHP token_generator.php):
23
+ # - json.dumps with ensure_ascii=False + sort_keys=True + COMPACT separators
24
+ # - PHP equivalent: ksort() + JSON_UNESCAPED_UNICODE (compact, no spaces)
25
+ #
26
+ # PORTED FROM fahd-agent-v1/plugins/edx/fahd_edx/middleware.py. TWO semantic
27
+ # changes only (see _create_token): (a) `tid` is the BARE tenant id (v1's
28
+ # "tenant:" prefix breaks v2 auth's CAST(:tid AS uuid)); (b) compact JSON
29
+ # separators (v1 used the default spaced separators, which were NOT byte-equal
30
+ # to v2 core/auth.py — the latent v1 byte-equivalence bug that v2 fixed).
31
+ # ============================================================
32
+
33
+ import base64
34
+ import hashlib
35
+ import hmac
36
+ import html
37
+ import json
38
+ import re
39
+ import time
40
+
41
+ from django.conf import settings as django_settings
42
+
43
+
44
+ class FahdMiddleware:
45
+ """Inject Fahd embed.js into every HTML response."""
46
+
47
+ def __init__(self, get_response):
48
+ self.get_response = get_response
49
+ self.fahd_url = getattr(django_settings, "FAHD_URL", "")
50
+ self.tenant_id = getattr(django_settings, "FAHD_TENANT_ID", "")
51
+ self.auth_secret = getattr(django_settings, "FAHD_AUTH_SECRET", "")
52
+
53
+ def __call__(self, request):
54
+ response = self.get_response(request)
55
+
56
+ if "text/html" not in response.get("Content-Type", ""):
57
+ return response
58
+ if not hasattr(request, "user") or not request.user.is_authenticated:
59
+ return response
60
+ # Fail-closed: a misconfigured install missing the URL, signing secret, OR
61
+ # tenant id injects NOTHING rather than minting permanently-invalid tokens
62
+ # (e.g. a "default" tid that no tenant row will ever match).
63
+ if not self.fahd_url or not self.auth_secret or not self.tenant_id:
64
+ return response
65
+
66
+ user = request.user
67
+ course_id = self._extract_course_id(request.path)
68
+ role = self._detect_role(user, course_id)
69
+ token = self._create_token(user, role, course_id)
70
+
71
+ # XSS defense-in-depth: escape settings-sourced values placed into HTML
72
+ # attributes (mirrors the M5-T01 view.php s() fix). `token` is base64url and
73
+ # therefore already attribute-safe (its alphabet excludes <>&"'), so it is
74
+ # left as-is.
75
+ safe_url = html.escape(self.fahd_url, quote=True)
76
+ safe_tenant = html.escape(self.tenant_id, quote=True)
77
+ script = (
78
+ f'<script src="{safe_url}/embed.js" '
79
+ f'data-tenant="{safe_tenant}" '
80
+ f'data-token="{token}" data-lang="ar" defer></script>'
81
+ )
82
+
83
+ # Guard non-UTF-8 legacy pages: if the body is not valid UTF-8, pass it
84
+ # through unchanged (no injection) rather than 500-ing on every such page.
85
+ try:
86
+ content = response.content.decode("utf-8")
87
+ except UnicodeDecodeError:
88
+ return response
89
+
90
+ # Inject ONCE, before the FIRST `</head>` only. replace-all would also
91
+ # rewrite stray `</head>` occurrences inside JS strings or <pre> blocks.
92
+ content = content.replace("</head>", f"{script}\n</head>", 1)
93
+ response.content = content.encode("utf-8")
94
+ response["Content-Length"] = len(response.content)
95
+ return response
96
+
97
+ def _extract_course_id(self, path):
98
+ """Extract course ID from URL path if inside a course."""
99
+ match = re.search(r"/courses/(course-v1:[^/]+)", path)
100
+ return match.group(1) if match else ""
101
+
102
+ def _detect_role(self, user, course_id):
103
+ """Detect user's role (student/teacher/admin) in the course."""
104
+ if user.is_staff or user.is_superuser:
105
+ return "admin"
106
+ if course_id:
107
+ try:
108
+ from opaque_keys.edx.keys import CourseKey
109
+ from student.roles import CourseInstructorRole, CourseStaffRole
110
+
111
+ key = CourseKey.from_string(course_id)
112
+ if CourseInstructorRole(key).has_user(user) or CourseStaffRole(key).has_user(user):
113
+ return "teacher"
114
+ except Exception:
115
+ # Graceful degradation (Architecture Rule #15): ANY failure here —
116
+ # a malformed course_id, the edX role imports being absent (e.g. when
117
+ # this middleware runs outside a full edX install), or a role-lookup
118
+ # DB error — degrades to "student". This token only carries a UI hint;
119
+ # the adapter's get_user_role stays the source of truth (Rule #9), so
120
+ # under-claiming the role here cannot grant unauthorized access.
121
+ pass
122
+ return "student"
123
+
124
+ def _create_token(self, user, role, course_id):
125
+ """Generate HMAC-SHA256 signed token for Fahd.
126
+
127
+ TWO v1->v2 retarget changes (the rest is verbatim from v1):
128
+
129
+ 1. BARE `tid`. v1 set `tid = f"tenant:{self.tenant_id}"` (a SurrealDB
130
+ record-id convention). v2 auth does `CAST(:tid AS uuid)`, which raises
131
+ on `"tenant:<uuid>"`, so a prefixed token would fail
132
+ verify_token_for_tenant. We emit the bare tenant id, matching v2
133
+ core/auth.py and core/moodle_bridge.build_fahd_payload.
134
+
135
+ 2. COMPACT separators=(",", ":"). v1 signed
136
+ `json.dumps(payload, ensure_ascii=False, sort_keys=True)` with the
137
+ DEFAULT separators (a space after every ":" and ","). PHP's
138
+ json_encode and v2 core/auth.py both emit COMPACT JSON (no spaces),
139
+ so v1's bytes — and therefore its HMAC — did NOT match. This is the
140
+ latent v1 byte-equivalence bug that v2 fixed; the compact separators
141
+ here make the inline token byte-identical to create_fahd_token.
142
+ """
143
+ now = int(time.time())
144
+ payload = {
145
+ "uid": str(user.id),
146
+ "roles": [role],
147
+ "tid": self.tenant_id,
148
+ "cid": course_id,
149
+ "cname": "",
150
+ "lang": "ar",
151
+ "platform": "edx",
152
+ "iat": now,
153
+ "exp": now + 3600,
154
+ }
155
+ # CRITICAL: must match v2 core/auth.py — ensure_ascii=False + sort_keys=True
156
+ # + COMPACT separators (no spaces). The separators are the load-bearing fix.
157
+ payload_bytes = json.dumps(
158
+ payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")
159
+ ).encode("utf-8")
160
+ sig = hmac.new(self.auth_secret.encode(), payload_bytes, hashlib.sha256).digest()
161
+ return (
162
+ base64.urlsafe_b64encode(payload_bytes).rstrip(b"=").decode()
163
+ + "."
164
+ + base64.urlsafe_b64encode(sig).rstrip(b"=").decode()
165
+ )
@@ -0,0 +1 @@
1
+ """Fahd edX plugin settings package (loaded by Open edX via apps.py plugin_app)."""
@@ -0,0 +1,28 @@
1
+ # ============================================================
2
+ # FILE: fahd_edx/settings/common.py
3
+ # PURPOSE: Register Fahd middleware and set config defaults
4
+ #
5
+ # HOW THIS WORKS:
6
+ # Open edX calls plugin_settings(settings) during startup.
7
+ # We append our middleware to the MIDDLEWARE list and set
8
+ # default config values. No Tutor patches needed.
9
+ #
10
+ # This runs for BOTH LMS and CMS (Studio).
11
+ # ============================================================
12
+
13
+
14
+ def plugin_settings(settings):
15
+ """
16
+ Called by Open edX during startup.
17
+ Appends Fahd middleware and sets config defaults.
18
+ """
19
+ # Register middleware — injects embed.js into every HTML page
20
+ settings.MIDDLEWARE.append("fahd_edx.middleware.FahdMiddleware")
21
+
22
+ # Config defaults (overridden by production.py or env vars)
23
+ settings.FAHD_URL = getattr(settings, "FAHD_URL", "http://localhost:8000")
24
+ # Fail-closed default: empty (not the non-UUID sentinel "default") so the
25
+ # middleware guard (`not self.tenant_id`) skips injection on a misconfigured
26
+ # install instead of minting tokens that fail server-side CAST(:tid AS uuid).
27
+ settings.FAHD_TENANT_ID = getattr(settings, "FAHD_TENANT_ID", "")
28
+ settings.FAHD_AUTH_SECRET = getattr(settings, "FAHD_AUTH_SECRET", "")
@@ -0,0 +1,24 @@
1
+ # ============================================================
2
+ # FILE: fahd_edx/settings/production.py
3
+ # PURPOSE: Production overrides — reads config from environment
4
+ #
5
+ # HOW THIS WORKS:
6
+ # Open edX calls plugin_settings(settings) AFTER common.py.
7
+ # We override defaults with environment variables if set.
8
+ #
9
+ # ENVIRONMENT VARIABLES:
10
+ # FAHD_URL — Fahd server URL (e.g., https://fahd.eduarabia.com)
11
+ # FAHD_TENANT_ID — Institution tenant ID
12
+ # FAHD_AUTH_SECRET — HMAC signing secret (must match Fahd server)
13
+ # ============================================================
14
+
15
+ import os
16
+
17
+
18
+ def plugin_settings(settings):
19
+ """
20
+ Production overrides — reads from environment variables.
21
+ """
22
+ settings.FAHD_URL = os.environ.get("FAHD_URL", settings.FAHD_URL)
23
+ settings.FAHD_TENANT_ID = os.environ.get("FAHD_TENANT_ID", settings.FAHD_TENANT_ID)
24
+ settings.FAHD_AUTH_SECRET = os.environ.get("FAHD_AUTH_SECRET", settings.FAHD_AUTH_SECRET)
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: fahd-edx-plugin
3
+ Version: 1.0.0
4
+ Summary: Fahd AI assistant for Open edX — floating chat on every page
5
+ Requires-Python: >=3.11
@@ -0,0 +1,10 @@
1
+ fahd_edx/__init__.py,sha256=vuJ_N8J1-OdxRp5r_tMg07oZDxTWHtlk5linlF1gTQk,115
2
+ fahd_edx/apps.py,sha256=bOOB-LJ4sLoNPD_EhKGWI9ZtBzVhWyzjEe4rxcBqsYE,1899
3
+ fahd_edx/middleware.py,sha256=0lrzJ9u8UfHve50wt5iH8iiREZE13paYEnWsloLKkdw,7444
4
+ fahd_edx/settings/__init__.py,sha256=Dtx-CgBO7a7Kq3THEEMfH8ll5LlHVn4hnE__sxVfNc8,84
5
+ fahd_edx/settings/common.py,sha256=xedhZ9idOHMkAwztPHIdjYZ_WsSBcDFkuc5QtEzSjBg,1262
6
+ fahd_edx/settings/production.py,sha256=porecd1_x619YP8ZOGPkYqDjmQAF8m7fz8pecCcLEfQ,969
7
+ fahd_edx_plugin-1.0.0.dist-info/METADATA,sha256=ZZbBWLyj8nqW5xFYo4ArPi-hZlhh7wWyq_-yVWlDXb8,155
8
+ fahd_edx_plugin-1.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
9
+ fahd_edx_plugin-1.0.0.dist-info/entry_points.txt,sha256=_tXDhd853PL-TeITwepu5Ft9gsCLTKKCiH4Os0jTOIg,117
10
+ fahd_edx_plugin-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,5 @@
1
+ [cms.djangoapp]
2
+ fahd_edx = fahd_edx.apps:FahdPluginConfig
3
+
4
+ [lms.djangoapp]
5
+ fahd_edx = fahd_edx.apps:FahdPluginConfig