fahd-edx-plugin 1.0.0__tar.gz

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,57 @@
1
+ # Secrets / env
2
+ .env
3
+ .env.*
4
+ !.env.example
5
+ !.env.staging.example
6
+ *.pem
7
+ *.key
8
+
9
+ # Live LMS tenant credentials for the seed script (real values, per-machine /
10
+ # secrets manager). Ignore the dir CONTENTS but keep the tracked .example template
11
+ # (must be `secrets/*` not `secrets/`, or Git can't re-include the example).
12
+ secrets/*
13
+ !secrets/*.example
14
+
15
+ # Python
16
+ __pycache__/
17
+ *.py[cod]
18
+ .venv/
19
+ venv/
20
+ .pytest_cache/
21
+ .mypy_cache/
22
+ .ruff_cache/
23
+ *.egg-info/
24
+ .coverage
25
+ htmlcov/
26
+
27
+ # uv
28
+ .uv/
29
+
30
+ # Node / Next.js (frontend/)
31
+ node_modules/
32
+ .next/
33
+ out/
34
+ .turbo/
35
+
36
+ # CodeGraph local DB/cache (config is committed; the index is per-machine)
37
+ .codegraph/*.db
38
+ .codegraph/*.db-*
39
+ .codegraph/cache/
40
+ .codegraph/*.log
41
+ .codegraph/.dirty
42
+
43
+ # Eval scorecards are committed in PRs intentionally; ignore only scratch runs
44
+ tests/eval/results/_scratch/
45
+
46
+ # Local dev server logs (backend/frontend run output — per-machine, never committed)
47
+ *.log
48
+
49
+ # OS / editor
50
+ .DS_Store
51
+ Thumbs.db
52
+ *.swp
53
+
54
+ # NOTE: fahd-agent-v1/ is vendored reference code (its nested .git was removed; original history
55
+ # lives at github.com/QusaiiSaleem/eduarabia-agent). It is tracked as plain files in this repo.
56
+
57
+ settings.local.json
@@ -0,0 +1,402 @@
1
+ # Fahd for Open edX — Integration Guide
2
+
3
+ > Three methods, from simplest to most integrated. Choose based on your deployment.
4
+
5
+ > **v2 retarget note:** Host URLs below (`https://<your-fahd-v2-host>`) and the Tutor dev
6
+ > default (`http://host.docker.internal:8000`) are **deployment placeholders** — set the real
7
+ > Fahd v2 host per deployment, never hardcode it (Architecture Rule #3). The GitHub remote is
8
+ > `tajahdev/fahd-agent`.
9
+
10
+ | Method | Works On | What You Get | Requires |
11
+ |--------|----------|-------------|----------|
12
+ | **A: LTI 1.3** | Any edX | Fahd in course navigation | Studio UI only |
13
+ | **B: Django Plugin** | Any edX | Floating button on every page | pip install + restart |
14
+ | **C: Tutor Plugin** | Tutor only | Same as B + easy config management | tutor CLI |
15
+
16
+ > **Distribution note:** Packages are currently installed from GitHub. PyPI publishing (`pip install fahd-edx-plugin`) is planned for the stable release.
17
+
18
+ ---
19
+
20
+ ## Emergency Rollback
21
+
22
+ If Fahd breaks your LMS after installation, remove it immediately:
23
+
24
+ ```bash
25
+ # Method B — remove the package and restart:
26
+ pip uninstall fahd-edx-plugin -y
27
+ # Then restart your LMS (tutor local restart lms / systemctl restart edxapp / kubectl rollout restart)
28
+
29
+ # Method C — disable the Tutor plugin:
30
+ tutor plugins disable fahd
31
+ tutor images build openedx
32
+ tutor local launch
33
+ ```
34
+
35
+ Fahd is a Django middleware — removing it restores your LMS to its original state with zero side effects.
36
+
37
+ ---
38
+
39
+ ## Method A: LTI 1.3 Integration (Simplest — Any Deployment)
40
+
41
+ **No CLI. No downtime. No Docker rebuild.** Done entirely through edX Studio.
42
+
43
+ Fahd appears as a link in course navigation. Students click to open the AI assistant. Authentication is handled via **LTI 1.3** (signed JWT launches, not shared secrets).
44
+
45
+ ### Setup
46
+
47
+ 1. In **edX Studio** → course → **Settings** → **Advanced Settings**
48
+ 2. **LTI Passports:** add `["fahd:YOUR_KEY:YOUR_SECRET"]` _(provided by EduArabia)_
49
+ 3. **Advanced Module List:** add `"lti_consumer"` if not already there
50
+ 4. Add an **LTI Consumer** component to your course:
51
+ - **LTI ID:** `fahd`
52
+ - **LTI URL:** `https://<your-fahd-v2-host>/lti/launch`
53
+ - **LTI Version:** `LTI 1.3` (Fahd v2 requires real JWKS verification)
54
+ - **Custom Parameters:** `tenant=YOUR_TENANT_ID`
55
+
56
+ > **LTI 1.3:** Fahd v2 uses signed JWTs with full JWKS verification + nonce/replay defense. Contact EduArabia for the OIDC login URL and JWKS endpoint.
57
+
58
+ ### Limitations
59
+
60
+ - Only inside courses (not on dashboard, profile, or grades pages)
61
+ - Must be configured per course
62
+ - Student clicks a link (no floating button)
63
+
64
+ ---
65
+
66
+ ## Method B: Django App Plugin (Recommended — Any Deployment)
67
+
68
+ **Works on Tutor, Kubernetes, native installs, managed hosting.** Just pip install and restart.
69
+
70
+ Fahd's floating button appears on **every legacy (server-rendered) HTML page** — dashboard, profile, grades, course about. (MFE/React pages are out of scope — see Known Limitations.)
71
+
72
+ ### Step 1: Install inside your edX environment
73
+
74
+ ```bash
75
+ # Pin to a specific release (recommended):
76
+ pip install "fahd-edx-plugin @ git+https://github.com/tajahdev/fahd-agent.git@v2.0.0#subdirectory=plugins/edx"
77
+
78
+ # Or latest (development only):
79
+ pip install "git+https://github.com/tajahdev/fahd-agent.git#subdirectory=plugins/edx"
80
+ ```
81
+
82
+ ### Step 2: Verify installation before restarting
83
+
84
+ ```bash
85
+ # Check package is installed:
86
+ pip show fahd-edx-plugin
87
+
88
+ # Check middleware is importable:
89
+ python -c "from fahd_edx.middleware import FahdMiddleware; print('OK')"
90
+ ```
91
+
92
+ If both commands succeed, the plugin is ready.
93
+
94
+ ### Step 3: Register your institution with Fahd
95
+
96
+ Before configuring the plugin, you need to register your edX instance with the Fahd v2 server. This gives you back the `FAHD_AUTH_SECRET` and `FAHD_TENANT_ID` that the plugin needs.
97
+
98
+ **3a. Create OAuth2 credentials in your edX admin:**
99
+
100
+ 1. Go to `https://YOUR_EDX_DOMAIN/admin/oauth2_provider/application/add/`
101
+ 2. Fill in:
102
+ - **Name:** `Fahd AI Assistant`
103
+ - **Client type:** `Confidential`
104
+ - **Authorization grant type:** `Client credentials`
105
+ - **Redirect URIs:** _(leave blank)_
106
+ 3. Save and copy the **Client ID** and **Client Secret**
107
+
108
+ **3b. Register with Fahd (one-time, takes 10 seconds):**
109
+
110
+ EduArabia provides the registration key. Run this command (replace the placeholders):
111
+
112
+ ```bash
113
+ curl -X POST https://<your-fahd-v2-host>/api/register \
114
+ -H "X-Registration-Key: <provided by EduArabia>" \
115
+ -H "Content-Type: application/json" \
116
+ -d '{
117
+ "name": "Your University Name",
118
+ "platform": "edx",
119
+ "lms_url": "https://YOUR_EDX_DOMAIN",
120
+ "credentials": {
121
+ "client_id": "<Client ID from step 3a>",
122
+ "client_secret": "<Client Secret from step 3a>"
123
+ }
124
+ }'
125
+ ```
126
+
127
+ **You'll get back:**
128
+
129
+ ```json
130
+ {
131
+ "tenant_id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
132
+ "auth_secret": "kT9x2mP...",
133
+ "fahd_url": "https://<your-fahd-v2-host>",
134
+ "status": "syncing"
135
+ }
136
+ ```
137
+
138
+ Save `tenant_id` and `auth_secret` — you need them in the next step.
139
+
140
+ > **Note:** In Fahd v2 the `tenant_id` is a **bare UUID** (e.g.,
141
+ > `3f2504e0-4f89-41d3-9a0c-0305e82c3301`). Use it exactly as returned. Do NOT add a
142
+ > `tenant:` prefix — v2 stores tenant ids as Postgres UUIDs and a prefixed value
143
+ > fails token verification.
144
+
145
+ ### Step 4: Set environment variables
146
+
147
+ Use the values from Step 3b:
148
+
149
+ ```bash
150
+ export FAHD_URL=https://<your-fahd-v2-host>
151
+ export FAHD_TENANT_ID=<tenant_id from Step 3b>
152
+ export FAHD_AUTH_SECRET=<auth_secret from Step 3b>
153
+ ```
154
+
155
+ **Where to set these depends on your deployment:**
156
+
157
+ | Deployment | Where to set |
158
+ |-----------|-------------|
159
+ | **Tutor** | Use Method C instead (easier) |
160
+ | **Kubernetes** | Helm values or ConfigMap |
161
+ | **Native** | `/edx/app/edxapp/edx-platform/lms/envs/private.py` |
162
+ | **Managed hosting** | Ask your provider to set them |
163
+
164
+ ### Step 5: Restart LMS
165
+
166
+ ```bash
167
+ # Tutor:
168
+ tutor local restart lms
169
+
170
+ # Kubernetes:
171
+ kubectl rollout restart deployment/lms
172
+
173
+ # Native:
174
+ sudo systemctl restart edxapp
175
+ ```
176
+
177
+ ### Step 6: Verify
178
+
179
+ Open any edX page. The floating blue button should appear in the bottom-right corner.
180
+
181
+ If not, view page source (`Ctrl+U`) and search for `embed.js`.
182
+
183
+ ### How auto-discovery works
184
+
185
+ ```
186
+ pip install fahd-edx-plugin
187
+ → Python registers lms.djangoapp entry point
188
+ → Open edX loads FahdPluginConfig (apps.py)
189
+ → apps.py declares plugin_app settings
190
+ → settings/common.py runs → appends FahdMiddleware to MIDDLEWARE
191
+ → settings/production.py runs → reads FAHD_* from env vars
192
+ → Middleware injects embed.js into every legacy HTML response
193
+ → No patches. No Tutor. No Docker rebuild.
194
+ ```
195
+
196
+ ---
197
+
198
+ ## Method C: Tutor Plugin (Easiest for Tutor Users)
199
+
200
+ **Wraps Method B** with Tutor config management. Auto-generates the auth secret.
201
+
202
+ ### Step 1: Install the Tutor plugin
203
+
204
+ ```bash
205
+ # Pin to a release (recommended):
206
+ pip install "tutor-fahd @ git+https://github.com/tajahdev/fahd-agent.git@v2.0.0#subdirectory=plugins/edx/tutor-fahd"
207
+
208
+ # Or latest (development only):
209
+ pip install "git+https://github.com/tajahdev/fahd-agent.git#subdirectory=plugins/edx/tutor-fahd"
210
+ ```
211
+
212
+ > You only install `tutor-fahd` here. It automatically installs `fahd-edx-plugin` inside the Docker container during the next build (Step 3).
213
+
214
+ ### Step 2: Register your institution
215
+
216
+ Follow **Method B, Step 3** (3a + 3b) to create OAuth2 credentials and register with Fahd. You'll get back a `tenant_id` and `auth_secret`.
217
+
218
+ ### Step 3: Enable and configure
219
+
220
+ ```bash
221
+ tutor plugins enable fahd
222
+ tutor config save --set FAHD_URL=https://<your-fahd-v2-host>
223
+ tutor config save --set FAHD_TENANT_ID=<tenant_id from registration>
224
+ tutor config save --set FAHD_AUTH_SECRET=<auth_secret from registration>
225
+ ```
226
+
227
+ > The Tutor wrapper's default `FAHD_URL` is `http://host.docker.internal:8000` — a
228
+ > dev convenience that reaches a Fahd v2 server running on your host machine from
229
+ > inside the edX container. Always override it with your real host in production.
230
+
231
+ ### Step 4: Build images and launch
232
+
233
+ ```bash
234
+ # Build the Docker image first (installs fahd-edx-plugin inside container):
235
+ tutor images build openedx
236
+
237
+ # Then launch (restarts services with new image):
238
+ tutor local launch
239
+ ```
240
+
241
+ > **Downtime warning:** `tutor local launch` restarts all services. Expect 5-10 minutes of downtime. Schedule during a maintenance window.
242
+
243
+ ---
244
+
245
+ ## Updating
246
+
247
+ ### Method B (Django Plugin)
248
+
249
+ ```bash
250
+ pip install --upgrade "fahd-edx-plugin @ git+https://github.com/tajahdev/fahd-agent.git@v2.1.0#subdirectory=plugins/edx"
251
+ # Restart LMS (no Docker rebuild needed)
252
+ ```
253
+
254
+ ### Method C (Tutor Plugin)
255
+
256
+ ```bash
257
+ pip install --upgrade "tutor-fahd @ git+https://github.com/tajahdev/fahd-agent.git@v2.1.0#subdirectory=plugins/edx/tutor-fahd"
258
+ tutor images build openedx # Rebuild image with updated fahd-edx-plugin
259
+ tutor local launch # Restart with new image
260
+ ```
261
+
262
+ ### Method A (LTI)
263
+
264
+ No updates needed on your side. The Fahd server updates independently.
265
+
266
+ > **Always pin to a version tag** (`@v2.0.0`) in production. Never install from `main` branch on a live LMS.
267
+
268
+ ---
269
+
270
+ ## Post-Install Testing Checklist
271
+
272
+ Run these tests after installation to confirm everything works. Takes ~5 minutes.
273
+
274
+ ### Test 1: Plugin is installed
275
+
276
+ ```bash
277
+ pip show fahd-edx-plugin
278
+ # Expected: Name: fahd-edx-plugin
279
+
280
+ python -c "from fahd_edx.middleware import FahdMiddleware; print('OK')"
281
+ # Expected: OK
282
+ ```
283
+
284
+ ### Test 2: Floating button appears (admin account)
285
+
286
+ 1. Log in as **admin** to your edX LMS
287
+ 2. Go to the **Dashboard** page
288
+ 3. Look for a floating blue circle button in the bottom-right corner
289
+
290
+ **If no button:** View page source (`Ctrl+U`), search for `embed.js`.
291
+
292
+ ### Test 3: Token has correct fields
293
+
294
+ 1. View page source on any logged-in page
295
+ 2. Find the `<script src="...embed.js" data-token="...">` tag
296
+ 3. Copy the `data-token` value (everything before the dot)
297
+ 4. Decode it:
298
+
299
+ ```bash
300
+ echo "<paste the part BEFORE the dot>" | python3 -c "
301
+ import base64, json, sys
302
+ token_part = sys.stdin.read().strip()
303
+ payload = json.loads(base64.urlsafe_b64decode(token_part + '=='))
304
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
305
+ "
306
+ ```
307
+
308
+ **Expected fields:**
309
+ ```json
310
+ {
311
+ "cid": "", ← empty on dashboard, course ID on course pages
312
+ "cname": "",
313
+ "exp": 1710600000, ← ~1 hour in the future
314
+ "iat": 1710596400, ← current timestamp
315
+ "lang": "ar",
316
+ "platform": "edx",
317
+ "roles": ["admin"], ← matches your role
318
+ "tid": "3f2504e0-4f89-41d3-9a0c-0305e82c3301", ← your tenant UUID (BARE — no "tenant:" prefix)
319
+ "uid": "2" ← your edX user ID
320
+ }
321
+ ```
322
+
323
+ ### Test 4: Student sees their own context
324
+
325
+ Log in as a **student**, decode the token, and confirm `roles` shows `["student"]`.
326
+
327
+ ### Test 5: Anonymous users don't see Fahd
328
+
329
+ Open an incognito window on the login page — there should be **no** `embed.js` script tag.
330
+
331
+ ### Test 6: Chat works
332
+
333
+ Log in, click the floating button, send a message. Fahd should respond.
334
+
335
+ **If chat shows "401" or "unauthorized":** The `FAHD_AUTH_SECRET` doesn't match what registration returned.
336
+
337
+ ### Test 7: Course context detected
338
+
339
+ Navigate to a course page and confirm `cid` now contains the course ID (e.g., `course-v1:Org+Course+Run`).
340
+
341
+ ---
342
+
343
+ ## Troubleshooting
344
+
345
+ ### Chat shows 401 error
346
+
347
+ `FAHD_AUTH_SECRET` mismatch. The value in your edX plugin must exactly match what the registration API returned.
348
+
349
+ - **Method B:** Check: `echo $FAHD_AUTH_SECRET`
350
+ - **Method C:** Check: `tutor config printvalue FAHD_AUTH_SECRET`
351
+
352
+ ### Fahd server is down — does it affect edX?
353
+
354
+ **No.** The script loads with `defer` (non-blocking). If the Fahd server is unreachable, the button appears grayed out. The middleware adds ~1ms overhead (string replacement only, zero network calls).
355
+
356
+ ---
357
+
358
+ ## Known Limitations
359
+
360
+ ### MFE (React) pages not covered — Phase 2 / out of scope
361
+
362
+ The middleware only injects `embed.js` into **server-rendered Django (legacy) pages**
363
+ (dashboard, profile, grades, course about, admin). It does **NOT** appear on **MFE
364
+ React pages** (Learning MFE, Studio MFE, Discussions MFE, etc.) — those are separate
365
+ React apps served as static files that Django middleware cannot reach.
366
+
367
+ MFE coverage via Frontend Plugin Slots is **phase-2 / out of scope** for this port.
368
+ The middleware deliberately only touches `text/html` responses; JSON / MFE responses
369
+ pass through untouched (there is a test for this:
370
+ `tests/integration/test_edx_token_verifies_on_v2.py::test_json_response_not_modified`).
371
+
372
+ **What's covered now:** Dashboard, Profile, Grades, Account Settings, Course About,
373
+ Admin pages, any server-rendered page.
374
+
375
+ **What's NOT covered:** Learning MFE (courseware), Studio MFE, Discussions MFE.
376
+
377
+ > For in-course access, use **Method A (LTI)** alongside Method B.
378
+
379
+ ---
380
+
381
+ ## Privacy & Data
382
+
383
+ The middleware generates a signed token containing only:
384
+
385
+ | Field | Value | Example |
386
+ |-------|-------|---------|
387
+ | `uid` | edX user ID | `42` |
388
+ | `roles` | Role in current context | `["student"]` |
389
+ | `tid` | Tenant UUID (bare) | `3f2504e0-4f89-41d3-9a0c-0305e82c3301` |
390
+ | `cid` | Course ID (if in a course) | `course-v1:KSU+CS101+2026` |
391
+ | `lang` | Interface language | `ar` |
392
+ | `platform` | Always "edx" | `edx` |
393
+ | `exp` | Token expiry (1 hour) | `1710600000` |
394
+
395
+ **No passwords, emails, or personal information** are included in the token.
396
+
397
+ ---
398
+
399
+ ## Support
400
+
401
+ - **Email:** support@eduarabia.com
402
+ - **Documentation:** https://docs.eduarabia.com/fahd
@@ -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,3 @@
1
+ """Fahd AI assistant — Django App Plugin for Open edX."""
2
+
3
+ default_app_config = "fahd_edx.apps.FahdPluginConfig"
@@ -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
+ }
@@ -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,35 @@
1
+ # ============================================================
2
+ # fahd-edx-plugin — Django App Plugin for Open edX
3
+ #
4
+ # THE MAIN PACKAGE. Works on ANY edX deployment:
5
+ # Tutor, Kubernetes, native, managed hosting.
6
+ #
7
+ # INSTALL:
8
+ # pip install fahd-edx-plugin
9
+ # # Set env vars: FAHD_URL, FAHD_TENANT_ID, FAHD_AUTH_SECRET
10
+ # # Restart LMS → Fahd appears on every page
11
+ #
12
+ # HOW IT WORKS:
13
+ # Open edX discovers this via lms.djangoapp entry point.
14
+ # apps.py → settings/common.py → appends middleware → done.
15
+ # ============================================================
16
+
17
+ [build-system]
18
+ requires = ["hatchling"]
19
+ build-backend = "hatchling.build"
20
+
21
+ [project]
22
+ name = "fahd-edx-plugin"
23
+ version = "1.0.0"
24
+ description = "Fahd AI assistant for Open edX — floating chat on every page"
25
+ requires-python = ">=3.11"
26
+ dependencies = []
27
+
28
+ [project.entry-points."lms.djangoapp"]
29
+ fahd_edx = "fahd_edx.apps:FahdPluginConfig"
30
+
31
+ [project.entry-points."cms.djangoapp"]
32
+ fahd_edx = "fahd_edx.apps:FahdPluginConfig"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["fahd_edx"]
@@ -0,0 +1,31 @@
1
+ # ============================================================
2
+ # tutor-fahd — Optional Tutor wrapper for Fahd
3
+ #
4
+ # For Tutor users who want easy config management.
5
+ # The actual plugin logic is in fahd-edx-plugin (separate package).
6
+ #
7
+ # INSTALL:
8
+ # pip install tutor-fahd
9
+ # tutor plugins enable fahd
10
+ # tutor config save --set FAHD_URL=https://fahd.eduarabia.com
11
+ # tutor local launch
12
+ # ============================================================
13
+
14
+ [build-system]
15
+ requires = ["hatchling"]
16
+ build-backend = "hatchling.build"
17
+
18
+ [project]
19
+ name = "tutor-fahd"
20
+ version = "1.0.0"
21
+ description = "Tutor plugin wrapper for Fahd AI assistant"
22
+ requires-python = ">=3.8"
23
+ dependencies = [
24
+ "tutor>=18.0.0,<22.0.0",
25
+ ]
26
+
27
+ [project.entry-points."tutor.plugin.v1"]
28
+ fahd = "tutorfahd.plugin"
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["tutorfahd"]
@@ -0,0 +1 @@
1
+ """Tutor plugin wrapper for Fahd — manages config and Docker installation."""
@@ -0,0 +1,87 @@
1
+ # ============================================================
2
+ # FILE: tutor-fahd/tutorfahd/plugin.py
3
+ # PURPOSE: Thin Tutor wrapper — config management + pip install
4
+ #
5
+ # THIS IS OPTIONAL. The fahd-edx-plugin Django App Plugin works
6
+ # without Tutor. This wrapper just makes it easier for Tutor users:
7
+ # 1. Manages config vars (FAHD_URL, FAHD_TENANT_ID, FAHD_AUTH_SECRET)
8
+ # 2. pip installs fahd-edx-plugin inside the Docker container
9
+ #
10
+ # NO settings patches needed — the Django App Plugin auto-registers
11
+ # itself via lms.djangoapp entry points.
12
+ #
13
+ # PATTERN FROM: cookiecutter-tutor-plugin, tutor-mfe
14
+ #
15
+ # PORTED FROM fahd-agent-v1/plugins/edx/tutor-fahd/tutorfahd/plugin.py.
16
+ # TWO v1->v2 retargets only (both are DEPLOYMENT PLACEHOLDERS — Rule #3,
17
+ # never hardcode a real host/remote; set per deployment):
18
+ # (a) FAHD_URL default -> http://host.docker.internal:8000 (v2 dev host:
19
+ # reaches the host machine's Fahd dev server from inside the edX
20
+ # container).
21
+ # (b) pip git URL -> the v2 repo subdirectory on `tajahdev/fahd-agent`;
22
+ # pin a release tag per deployment.
23
+ # The lms/cms env patches are unchanged.
24
+ # ============================================================
25
+
26
+ from tutor import hooks
27
+
28
+ # ── Config defaults ──────────────────────────────────────────
29
+ # Admins override via: tutor config save --set FAHD_URL=https://...
30
+
31
+ hooks.Filters.CONFIG_DEFAULTS.add_items(
32
+ [
33
+ # v2 dev default: reach the host's Fahd dev server from the container.
34
+ # DEPLOYMENT PLACEHOLDER — set the real URL per deployment (Rule #3).
35
+ ("FAHD_URL", "http://host.docker.internal:8000"),
36
+ ("FAHD_TENANT_ID", "default"),
37
+ ]
38
+ )
39
+
40
+ # AUTH_SECRET uses CONFIG_UNIQUE — Tutor auto-generates a random
41
+ # 24-character value. No insecure defaults.
42
+ hooks.Filters.CONFIG_UNIQUE.add_items(
43
+ [
44
+ ("FAHD_AUTH_SECRET", "{{ 24|random_string }}"),
45
+ ]
46
+ )
47
+
48
+ # ── Install fahd-edx-plugin inside the Docker container ──────
49
+ # The Django App Plugin package must exist inside the edX container
50
+ # for auto-discovery to work. This Dockerfile patch adds it.
51
+ #
52
+ # The v2 repo remote is `tajahdev/fahd-agent`; pin a release tag per deployment
53
+ # (never install from main on a live LMS) (Rule #3).
54
+
55
+ hooks.Filters.ENV_PATCHES.add_item(
56
+ (
57
+ "openedx-dockerfile-post-python-requirements",
58
+ 'RUN pip install "fahd-edx-plugin @ '
59
+ 'git+https://github.com/tajahdev/fahd-agent.git#subdirectory=plugins/edx"',
60
+ )
61
+ )
62
+
63
+ # ── Pass config as environment variables ─────────────────────
64
+ # The Django App Plugin reads FAHD_* from env vars (production.py).
65
+ # This patch sets them in the LMS/CMS container environment.
66
+
67
+ hooks.Filters.ENV_PATCHES.add_item(
68
+ (
69
+ "openedx-lms-common-settings",
70
+ """
71
+ FAHD_URL = "{{ FAHD_URL }}"
72
+ FAHD_TENANT_ID = "{{ FAHD_TENANT_ID }}"
73
+ FAHD_AUTH_SECRET = "{{ FAHD_AUTH_SECRET }}"
74
+ """,
75
+ )
76
+ )
77
+
78
+ hooks.Filters.ENV_PATCHES.add_item(
79
+ (
80
+ "openedx-cms-common-settings",
81
+ """
82
+ FAHD_URL = "{{ FAHD_URL }}"
83
+ FAHD_TENANT_ID = "{{ FAHD_TENANT_ID }}"
84
+ FAHD_AUTH_SECRET = "{{ FAHD_AUTH_SECRET }}"
85
+ """,
86
+ )
87
+ )