> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# مختبرات البرمجة للطلاب في المتصفح مع JupyterHub
- URL: https://arabroot.io/articles/مختبرات-البرمجة-والبيانات-للطلاب-مع-jupyterhub/
- Published: 2026-10-14T11:00:00.000Z
- Updated: 2026-10-25T08:50:18.000Z
- Description: شغل JupyterHub ليكتب كل طالب الكود في المتصفح دون تثبيت شيء، وصحح الواجبات تلقائياً بـ nbgrader مع معرفة ما يلزمها من عزل.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, التعليم والبحث العلمي, الاستضافة الذاتية, JupyterHub

في أول محاضرة من مقرر البرمجة أو تحليل البيانات تضيع نصف الساعة الأولى في تثبيت Python على أجهزة الطلاب: طالب على Windows ونسخة Python قديمة، وطالب على Mac ومكتبة لا تتثبت، وطالب جهازه ضعيف لا يحتمل pandas، والمعلم يركض بين الصفوف بدلاً من أن يشرح. والحل الذي تستخدمه الجامعات هو أن يكون مختبر البرمجة على السيرفر، فيفتح الطالب المتصفح ويجد بيئة Jupyter جاهزة بالمكتبات نفسها التي يستخدمها زملاؤه، وهذا ما يقدمه JupyterHub. وفي [الأنظمة التي تحتاجها الجامعة](https://arabroot.io/articles/%D8%A7%D9%84%D8%A7%D9%86%D8%B8%D9%85%D8%A9-%D8%A7%D9%84%D8%AA%D9%8A-%D8%AA%D8%AD%D8%AA%D8%A7%D8%AC%D9%87%D8%A7-%D8%A7%D9%84%D8%AC%D8%A7%D9%85%D8%B9%D8%A9-%D9%85%D9%86-%D8%A7%D9%84%D9%82%D8%A8%D9%88%D9%84-%D8%A7%D9%84%D9%89-%D8%A7%D9%84%D8%AA%D8%AE%D8%B1%D8%AC/) ذكرنا مختبرات البرمجة بين الأنظمة التي تكمل منصة التعلم، والآن نشغلها فعلاً.

وفي هذا المقال سوف نتناول:

- كيف يعمل JupyterHub من الداخل، وأي طريقة تثبيت تناسب فصلاً واحداً، وأيها تناسب جامعة.
- مختبر على Docker فيه معلمة وثلاثة طلاب، وكم يستهلك كل طالب من الذاكرة فعلاً، وما الذي حدث عندما بدأت ثلاثة سيرفرات في الوقت نفسه.
- دورة واجب كاملة بـ nbgrader: إنشاء الواجب، وتوزيعه، وتسليمه، والتصحيح الآلي، وملف الدرجات.
- ثلاث نقاط أمان وجدناها في التجربة: حد الذاكرة الذي لا يطبق، ومجلد التسليم، وكود الطالب الذي يعمل بحساب المعلم.

## كيف يعمل JupyterHub؟

Jupyter وحده برنامج لمستخدم واحد، يشغله الشخص على جهازه ويفتح دفاتره Notebooks في المتصفح. وJupyterHub يضع أمامه بوابة لعدة مستخدمين، فعندما يدخل الطالب يشغل له سيرفر Jupyter خاصاً به، ويوجه طلباته إليه. والشكل التالي يبين المكونات كما شغلناها، مع الذاكرة التي قسناها لكل منها:

![مخطط: متصفحات الطلاب تصل إلى الوكيل Proxy وهو configurable-http-proxy بذاكرة 80MB، والوكيل يوجه المسار hub إلى المحور Hub وفيه المصادقة Authenticator والمشغل Spawner وقاعدة بيانات المستخدمين بذاكرة 143MB، ويوجه المسار user name إلى سيرفر Jupyter مستقل لكل طالب بحساب Linux الخاص به، ثلاثة سيرفرات لكل منها نحو 168MB، وتحتها مجلد كل طالب في home ومجلد التبادل Exchange](https://arabroot.io/content/images/2026/10/jupyterhub-education-01-hub-parts.webp)

بوابة واحدة، وسيرفر Jupyter مستقل لكل طالب يبدأ عند دخوله

في الشكل أعلاه لاحظ التالي:

- **الوكيل Proxy:** البرنامج الوحيد الذي يستقبل طلبات المتصفح، ويوجه `/hub/` إلى المحور و`/user/<name>/` إلى سيرفر الطالب.
- **المصادقة Authenticator:** تقرر من هو المستخدم، والافتراضية PAM أي حسابات Linux على السيرفر، و[قائمة المصادقات](https://jupyterhub.readthedocs.io/en/stable/reference/authenticators.html?ref=arabroot.io) فيها OAuth لربطه بمزود هوية مثل authentik الذي ربطنا به Moodle في [دخول موحد إلى Moodle](https://arabroot.io/articles/%D8%AF%D8%AE%D9%88%D9%84-%D9%85%D9%88%D8%AD%D8%AF-%D9%84%D9%84%D8%B7%D9%84%D8%A7%D8%A8-%D9%88%D8%A7%D9%84%D9%85%D8%B9%D9%84%D9%85%D9%8A%D9%86-authentik-%D9%85%D8%B9-moodle/).
- **المشغل Spawner:** يقرر أين يعمل سيرفر الطالب، وهو أهم قرار في التصميم كله، والجدول التالي يبين الخيارات.

| الطريقة                                                                                                                            | أين يعمل سيرفر الطالب                      | حد الذاكرة لكل طالب         | يناسب                                       |
| ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | --------------------------- | ------------------------------------------- |
| LocalProcessSpawner (الافتراضي)                                                                                                    | عملية Process بحساب Linux على السيرفر نفسه | لا يطبق                     | التجربة، أو فصل صغير تثق بطلابه             |
| [The Littlest JupyterHub](https://tljh.jupyter.org/en/latest/topic/tljh-config.html?ref=arabroot.io)                               | سيرفر واحد، وكل طالب في وحدة systemd       | يطبق بالإعداد limits.memory | مقرر أو قسم حتى عشرات الطلاب على سيرفر واحد |
| [DockerSpawner](https://jupyterhub-dockerspawner.readthedocs.io/en/latest/?ref=arabroot.io)                                        | Container لكل طالب                         | يطبقه Docker                | سيرفر واحد مع عزل أقوى                      |
| [Zero to JupyterHub على Kubernetes](https://z2jh.jupyter.org/en/stable/jupyterhub/customizing/user-resources.html?ref=arabroot.io) | Pod لكل طالب على عدة سيرفرات               | يطبقه Kubernetes            | جامعة بمئات الطلاب في الوقت نفسه            |

وقد يتساءل البعض: لماذا نبدأ بالطريقة الأولى إذا كانت لا تطبق الحدود؟ والإجابة أنها الطريقة التي يفترضها دليل nbgrader لمعلم واحد ومقرر واحد، وأنها تجعل كل مكون ظاهراً، فنرى بأعيننا ما الذي تضيفه الطرق الأخرى، وسوف نقيس ذلك في قسم الأمان.

## المتطلبات

- **الذاكرة:** في مختبرنا استخدم المحور 143MB والوكيل 80MB، وكل سيرفر طالب نحو 168MB وهو خامل قبل أن يشغل أي كود، فثلاثون طالباً في محاضرة واحدة يعنون نحو 5GB قبل أن يحمل أحدهم ملف بيانات، ثم يضاف ما تستهلكه pandas والبيانات نفسها، وهذا هو الرقم الذي تخطط عليه وليس عدد الطلاب المسجلين في المقرر.
- **نطاق وشهادة HTTPS:** مثل `https://lab.example.com` عبر Reverse Proxy كما في [تثبيت Caddy](https://arabroot.io/articles/%D8%AA%D8%AB%D8%A8%D9%8A%D8%AA-caddy-reverse-proxy/)، والسبب أن كلمات مرور الطلاب ورموز الجلسة تمر عبره، ولاحظ أن الوكيل يحتاج دعم WebSocket لأن الدفتر يتصل بالنواة Kernel عبره.
- **حساب لكل طالب:** سواء كان حساب Linux كما في المختبر، أو حساباً من مزود الهوية.

## المختبر: JupyterHub وnbgrader على Docker

بنينا Docker Image فوق ال Image الرسمي `quay.io/jupyterhub/jupyterhub:6.1.1`، وأضفنا إليه JupyterLab وnbgrader ومكتبات البيانات، وأنشأنا حسابات Linux للمعلمة وثلاثة طلاب:

```dockerfile
FROM quay.io/jupyterhub/jupyterhub:6.1.1
RUN python3 -m pip install --no-cache-dir jupyterlab==4.6.4 notebook==7.6.3 nbgrader==0.9.6 \
    numpy==2.3.4 pandas==2.3.3 matplotlib==3.10.7
# One instructor and three students as Linux users (the lab uses PAM, the default authenticator)
RUN for u in t.sara s2026001 s2026002 s2026003; do useradd -m -s /bin/bash "$u" && echo "$u:Lab-Pass-2026!" | chpasswd; done \
 && mkdir -p /srv/nbgrader/exchange && chmod 777 /srv/nbgrader/exchange
COPY jupyterhub_config.py /srv/jupyterhub/jupyterhub_config.py
COPY nbgrader_config.py /etc/jupyter/nbgrader_config.py
WORKDIR /srv/jupyterhub
CMD ["jupyterhub", "-f", "/srv/jupyterhub/jupyterhub_config.py"]
```

ثم إعداد JupyterHub في `jupyterhub_config.py`:

```python
c = get_config()  # noqa
c.JupyterHub.bind_url = "http://0.0.0.0:8000"
c.Authenticator.allowed_users = {"t.sara", "s2026001", "s2026002", "s2026003"}
c.Authenticator.admin_users = {"t.sara"}
c.Spawner.default_url = "/lab"
c.Spawner.mem_limit = "512M"
# A service token for the lab script that starts and stops the students' servers
c.JupyterHub.services = [{"name": "lab-runner", "api_token": "lab-hub-api-token-4c8d"}]
c.JupyterHub.load_roles = [{"name": "lab-runner", "scopes": ["admin:users", "admin:servers"], "services": ["lab-runner"]}]
c.Spawner.http_timeout = 120
c.Spawner.start_timeout = 180
```

وإعداد nbgrader المشترك في `/etc/jupyter/nbgrader_config.py`، وفيه اسم المقرر ومجلد المعلمة ومجلد التبادل الذي تمر عبره الواجبات بين المعلم والطلاب:

```python
c = get_config()  # noqa
c.CourseDirectory.course_id = "py101"
c.CourseDirectory.root = "/home/t.sara/py101"
c.Exchange.root = "/srv/nbgrader/exchange"
```

```yaml
name: edu3-jh
services:
  hub:
    image: edu3-jhub:6.1.1
    container_name: edu3-jh-hub
    ports: ["127.0.0.1:29280:8000"]
    volumes: ["home:/home", "exchange:/srv/nbgrader/exchange"]
    mem_limit: 2g
volumes:
  home: { name: edu3-jh-home }
  exchange: { name: edu3-jh-exchange }
networks:
  default: { name: edu3-jh-net }
```

```bash
docker build -t edu3-jhub:6.1.1 .
docker compose up -d
```

في الإعداد أعلاه لاحظ التالي:

- **Python داخل ال Image:** ال Image الرسمي مبني على Ubuntu 26.04.1، وكل حزم JupyterHub في بيئة افتراضية Virtual Environment في `/srv/venv`، بينما الأمر `python3` في جلسة `su - s2026001` هو Python النظام الذي لا يعرف nbgrader، فأول أمر شغلناه بحساب المعلمة فشل بالرسالة `ModuleNotFoundError: No module named 'nbformat'`. لذلك في الأوامر التالية من سطر الأوامر نضيف `/srv/venv/bin` إلى PATH، وأما في الطرفية Terminal داخل JupyterLab فالبيئة صحيحة.
- **مجلدات الطلاب:** أنشأ `useradd` كل مجلد بالصلاحيات `drwxr-x---`، فلا يرى الطالب مجلد زميله، وجربنا ذلك فكانت النتيجة `Permission denied`. وهذا من إعدادات النظام في الإصدارات الحديثة من Ubuntu، فتأكد منه في نظامك ولا تفترضه.
- **ال Volume `home`:** كل عمل الطلاب فيه، فهو أهم ما تنسخه احتياطياً.
- **الإصدارات:** [JupyterHub 6.1.1](https://github.com/jupyterhub/jupyterhub/releases/tag/6.1.1?ref=arabroot.io) و[nbgrader 0.9.6](https://github.com/jupyter/nbgrader/releases/tag/v0.9.6?ref=arabroot.io) كانا أحدث الإصدارات عند التجربة.

![صفحة دخول JupyterHub: شعار jupyterhub في الأعلى، ونموذج Sign in فيه اسم المستخدم s2026001 وكلمة المرور وزر Sign in برتقالي](https://arabroot.io/content/images/2026/10/jupyterhub-education-02-hub-login.webp)

صفحة الدخول الافتراضية، والحساب هنا حساب Linux على السيرفر

### ثلاثة طلاب يبدؤون في اللحظة نفسها

في الإعداد الأول لم يكن السطران الأخيران من `jupyterhub_config.py` موجودين، وطلبنا من JupyterHub تشغيل سيرفرات الطلاب الثلاثة معاً عبر الواجهة البرمجية، كما يحدث عندما يفتح الفصل كله الصفحة في بداية المحاضرة:

```bash
for u in s2026001 s2026002 s2026003; do
  curl -s -o /dev/null -w "$u %{http_code}\n" -X POST -H 'Authorization: token lab-hub-api-token-4c8d' \
    http://127.0.0.1:29280/hub/api/users/$u/server
done
```

قبل JupyterHub الطلبات الثلاثة بالرمز 202، ولكن لم يصبح أي سيرفر جاهزاً، وفي السجل رسائل تقترح الحل:

```text
       This may be a configuration issue specific to your chosen Spawner.
       To fix: increase `Spawner.http_timeout` configuration
       to a number of seconds that is enough for servers to become responsive.
```

والقيمة الافتراضية لـ `http_timeout` هي 30 ثانية بحسب [مرجع ال Spawner](https://jupyterhub.readthedocs.io/en/stable/reference/api/spawner.html?ref=arabroot.io)، وعلى سيرفر مشغول يحتاج سيرفر الطالب أكثر منها ليبدأ، فيعتبره JupyterHub فاشلاً ويوقفه. بعد رفع المهلة إلى 120 ثانية وتشغيل الطلاب واحداً بعد الآخر أصبحت السيرفرات جاهزة بعد 34 و31 و32 ثانية. والدرس: جرب على السيرفر الحقيقي سيناريو بداية المحاضرة، أي ثلاثين طالباً يدخلون في الدقيقة نفسها، وارفع المهلة بحسب ما تقيسه، كما شرحنا في [اختبار الأحمال والضغط](https://arabroot.io/articles/%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1-%D8%A7%D9%84%D8%A7%D8%AD%D9%85%D8%A7%D9%84-%D9%88%D8%A7%D9%84%D8%B6%D8%BA%D8%B7-load-%D9%88-stress-testing-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/).

## الواجبات والتصحيح الآلي مع nbgrader

nbgrader أداة لإنشاء الواجبات في دفاتر Jupyter وتصحيحها، و[دليل إنشاء الواجبات](https://nbgrader.readthedocs.io/en/stable/user%5Fguide/creating%5Fand%5Fgrading%5Fassignments.html?ref=arabroot.io) يشرح الفكرة: المعلم يكتب دفتراً واحداً فيه الحل والاختبارات، والأداة تخرج منه نسخة الطالب دون الحل، ثم تصحح ما يسلمه الطالب بتشغيل الاختبارات عليه. والشكل التالي يبين الدورة كاملة كما نفذناها:

![مخطط بثمان خطوات في ثلاثة مسارات: المعلمة t.sara تكتب دفتر المصدر بالحل والاختبارات، ثم generate_assignment يحذف الحل والاختبارات المخفية، ثم release_assignment إلى مجلد التبادل py101/outbound/hw1 الذي يقرؤه كل الطلاب، ثم الطالب يجلب الواجب بالأمر fetch_assignment ويكتب الحل ويسلم بالأمر submit إلى py101/inbound الذي يكتب فيه ولا يرى محتواه، ثم المعلمة تجمع بالأمر collect وتصحح بالأمر autograde وهي خطوة حمراء لأنها تشغل الكود بحساب المعلم، ثم export إلى grades.csv](https://arabroot.io/content/images/2026/10/jupyterhub-education-04-nbgrader-cycle.webp)

من دفتر المعلم إلى ملف الدرجات، والخطوة 7 هي التي تحتاج انتباهك

### الخطوة 1: دفتر المصدر

كل خلية في دفتر المصدر لها بيانات nbgrader في الحقل `metadata`، تقول هل هي خلية حل يكتب فيها الطالب، أم خلية اختبار مقفلة لها درجة. وفي الواجهة تضعها من شريط Create Assignment على يمين JupyterLab، ونحن أنشأنا الدفتر بسكربت حتى يكون قابلاً للتكرار:

```python
# Writes the instructor's source notebook py101/source/hw1/hw1.ipynb with nbgrader cell metadata:
# two answer cells (solution) and two locked test cells worth 2 and 3 points, the second with hidden tests.
import nbformat as nbf, os

def md(text, gid):
    c = nbf.v4.new_markdown_cell(text)
    c.metadata["nbgrader"] = {"grade": False, "grade_id": gid, "locked": True, "schema_version": 3, "solution": False, "task": False}
    return c

def code(src, gid, solution=False, points=None):
    c = nbf.v4.new_code_cell(src)
    c.metadata["nbgrader"] = {"grade": points is not None, "grade_id": gid, "locked": not solution,
                              "schema_version": 3, "solution": solution, "task": False}
    if points is not None:
        c.metadata["nbgrader"]["points"] = points
    return c

nb = nbf.v4.new_notebook()
nb.cells = [
    md("# الواجب الأول: درجات الطلاب\nاكتب الدالتين التاليتين، ثم شغل خلايا الاختبار للتأكد من إجابتك قبل التسليم.", "intro"),
    md("**السؤال 1:** اكتب الدالة `average(grades)` التي ترجع متوسط الدرجات، وتتجاهل القيم `None` (طالب غائب).", "q1"),
    code("def average(grades):\n    ### BEGIN SOLUTION\n    marks = [g for g in grades if g is not None]\n    return sum(marks) / len(marks)\n    ### END SOLUTION", "average", solution=True),
    code("assert average([80, 90, 100]) == 90\nassert average([70, None, 90]) == 80", "test_average", points=2),
    md("**السؤال 2:** اكتب الدالة `letter(score)` التي ترجع «ممتاز» من 90 فما فوق، و«جيد جداً» من 80، و«جيد» من 70، و«راسب» لما دون ذلك.", "q2"),
    code("def letter(score):\n    ### BEGIN SOLUTION\n    if score >= 90:\n        return \"ممتاز\"\n    if score >= 80:\n        return \"جيد جداً\"\n    if score >= 70:\n        return \"جيد\"\n    return \"راسب\"\n    ### END SOLUTION", "letter", solution=True),
    code("assert letter(95) == \"ممتاز\"\nassert letter(85) == \"جيد جداً\"\n### BEGIN HIDDEN TESTS\nassert letter(90) == \"ممتاز\"\nassert letter(69.5) == \"راسب\"\n### END HIDDEN TESTS", "test_letter", points=3),
]
nb.metadata["kernelspec"] = {"name": "python3", "display_name": "Python 3", "language": "python"}
os.makedirs("py101/source/hw1", exist_ok=True)
nbf.write(nb, "py101/source/hw1/hw1.ipynb")
print("wrote py101/source/hw1/hw1.ipynb with", len(nb.cells), "cells")
```

في السكربت أعلاه لاحظ التالي:

- ما بين `### BEGIN SOLUTION` و`### END SOLUTION` هو الحل، ويحذفه nbgrader من نسخة الطالب ويضع مكانه `raise NotImplementedError()`.
- ما بين `### BEGIN HIDDEN TESTS` و`### END HIDDEN TESTS` اختبارات لا يراها الطالب، وتعود عند التصحيح، وهي هنا تختبر الحدود: الدرجة 90 بالضبط، والدرجة 69.5.

### الخطوتان 2 و3: إنشاء نسخة الطالب وتوزيعها

```bash
su - t.sara -c 'export PATH=/srv/venv/bin:$PATH; python3 /tmp/make_assignment.py && cd py101 && nbgrader generate_assignment hw1 --create && nbgrader release_assignment hw1'
```

```text
wrote py101/source/hw1/hw1.ipynb with 7 cells
[GenerateAssignmentApp | INFO] Updating/creating assignment 'hw1': {}
[GenerateAssignmentApp | INFO] Converting notebook /home/t.sara/py101/source/./hw1/hw1.ipynb
[GenerateAssignmentApp | INFO] Making directory /home/t.sara/py101/release/./hw1
[GenerateAssignmentApp | INFO] Writing 3795 bytes to /home/t.sara/py101/release/hw1/hw1.ipynb
[GenerateAssignmentApp | INFO] Setting destination file permissions to 644
[ReleaseAssignmentApp | INFO] Source: /home/t.sara/py101/release/./hw1
[ReleaseAssignmentApp | INFO] Destination: /srv/nbgrader/exchange/py101/outbound/hw1
[ReleaseAssignmentApp | INFO] Released as: py101 hw1
```

وهذه خلايا الكود في النسخة التي وصلت إلى مجلد التبادل، أي ما يراه الطالب:

```text
def average(grades):
    # YOUR CODE HERE
    raise NotImplementedError()
---
assert average([80, 90, 100]) == 90
assert average([70, None, 90]) == 80
---
def letter(score):
    # YOUR CODE HERE
    raise NotImplementedError()
---
assert letter(95) == "ممتاز"
assert letter(85) == "جيد جداً"
```

الحل اختفى، والاختبارات المخفية اختفت، وبقيت الاختبارات الظاهرة ليجرب بها الطالب إجابته قبل التسليم.

### الخطوتان 4 و5: الطلاب يجلبون ويسلمون

من JupyterLab يجد الطالب الواجب في قائمة Nbgrader ثم Assignment List، ويضغط Fetch ثم Submit، والأمران المقابلان في سطر الأوامر هما `nbgrader fetch_assignment hw1 --course py101` و`nbgrader submit hw1 --course py101`. وجعلنا كل طالب يكتب إجابة مختلفة:

- **`s2026001`:** إجابة صحيحة.
- **`s2026002`:** متوسط يقسم على عدد الدرجات مع الغائبين، ودرجة «ممتاز» تبدأ بعد 90 وليس من 90، أي خطأ في الحدود لا تكشفه الاختبارات الظاهرة.
- **`s2026003`:** إجابة صحيحة، ومعها سطر يكتب في ملف اسم المستخدم الذي يشغل الكود والمجلد الذي يعمل فيه، وسوف نعرف سببه بعد قليل.

```text
== s2026001
[FetchAssignmentApp | INFO] Destination: /home/s2026001/hw1
[FetchAssignmentApp | INFO] Fetched as: py101 hw1
answered correct
[SubmitApp | INFO] Source: /home/s2026001/hw1
[SubmitApp | INFO] Destination: /srv/nbgrader/exchange/py101/inbound/s2026001+hw1+2026-10-22 01:07:40.749315 UTC+jQrwWzFOeYso
[SubmitApp | INFO] Submitted as: py101 hw1 2026-10-22 01:07:40.749315 UTC
```

والصورة التالية للدفتر في JupyterLab بحساب الطالب بعد أن كتب إجابته:

![JupyterLab بحساب الطالب s2026001: المجلد hw1 وفيه hw1.ipynb، والدفتر مفتوح بعنوان الواجب الأول درجات الطلاب، ونص السؤالين بالعربية، وخلية الدالة average بإجابة الطالب، وخلية اختبار فيها assert، ثم خلية الدالة letter بإجابة الطالب وخلية اختبار، وفي القائمة العلوية قائمة Nbgrader، وعلى اليمين شريط Create Assignment](https://arabroot.io/content/images/2026/10/jupyterhub-education-03-student-notebook.webp)

دفتر الطالب: العناوين العربية تظهر بخط صحيح، ولكن الفقرات بمحاذاة اليسار وعلامات الترقيم في غير مكانها

لاحظ في الصورة أن JupyterLab يعرض نص الخلايا العربي من اليسار، فتظهر النقطة والقوس في أول السطر، وهذا مزعج في واجب عربي طويل، ومن الحلول التي تستحق التجربة أن تضع نص السؤال داخل وسم HTML يحدد اتجاه الكتابة `<div dir="rtl">` في خلية Markdown، ثم تتأكد من شكله عند الطالب قبل توزيع الواجب.

### الخطوات 6 إلى 8: الجمع والتصحيح والدرجات

```bash
su - t.sara -c 'export PATH=/srv/venv/bin:$PATH; cd py101 && nbgrader collect hw1 && nbgrader autograde hw1 && nbgrader export && cat grades.csv'
```

```text
[CollectApp | INFO] Processing 3 submissions of 'hw1' for course 'py101'
[CollectApp | INFO] Collecting submission: s2026001 hw1
[CollectApp | INFO] Collecting submission: s2026002 hw1
[CollectApp | INFO] Collecting submission: s2026003 hw1
[AutogradeApp | WARNING] Attribute 'checksum' for cell test_letter has changed! (should be: b7b536840c3f16c0c88debf8a7403fee, got: 9ee79326aa2c469f7e1f55fbe5375f19)
[AutogradeApp | INFO] Autograding /home/t.sara/py101/autograded/s2026001/hw1/hw1.ipynb
...
[AutogradeApp | INFO] Overwriting files with master versions from the source directory
...
[ExportApp | INFO] Exporting grades to grades.csv
assignment,duedate,timestamp,student_id,last_name,first_name,email,raw_score,late_submission_penalty,score,max_score
hw1,,2026-10-22 01:07:40.749315,s2026001,,,,5.0,0.0,5.0,5.0
hw1,,2026-10-22 01:08:21.700744,s2026002,,,,0.0,0.0,0.0,5.0
hw1,,2026-10-22 01:08:59.915875,s2026003,,,,5.0,0.0,5.0,5.0
```

في المخرج أعلاه لاحظ التالي:

- **تحذير checksum:** ظهر لكل طالب على خلية `test_letter`، والسبب أن نسخة الطالب لا تحتوي الاختبارات المخفية، فبصمة الخلية تختلف عن بصمة المصدر، ثم يعيد nbgrader الخلايا المقفلة من نسخة المصدر كما في السطر «Overwriting files with master versions»، فالاختبارات المخفية تعمل والطالب لا يستطيع أن يغير خلايا الاختبار ليجتاز الواجب.
- **درجة الطالب الثاني صفر:** عند سؤال قاعدة الدرجات Gradebook عن كل خلية كانت النتيجة `test_average 0.0 من 2.0` و`test_letter 0.0 من 3.0`. والسؤال الثاني هو المهم، فالاختباران الظاهران نجحا عند الطالب قبل التسليم، والاختبار المخفي `letter(90)` هو الذي كشف خطأ الحدود، وهذا بالضبط سبب وجود الاختبارات المخفية. ولاحظ أن الخلية كلها تأخذ صفراً إذا فشل فيها سطر واحد، فاجعل كل خلية اختبار تختبر فكرة واحدة إذا أردت درجات جزئية.
- **الأعمدة الفارغة:** الاسم والبريد فارغان في الملف، لأن الطلاب أضيفوا إلى قاعدة الدرجات بأسماء المستخدمين فقط، فأضف الطلاب قبل الواجب الأول بالأمر `nbgrader db student import` من ملف CSV حتى يخرج ملف الدرجات جاهزاً للرفع إلى منصة التعلم.

والصورة التالية لواجهة التصحيح formgrader بحساب المعلمة، وفيها الواجب وعدد التسليمات:

![صفحة nbgrader بعنوان Manage Assignments، وعلى اليسار روابط Manage Assignments وManual Grading وManage Students، وفي الجدول الواجب hw1 بحالة released وأمامه أيقونات Edit وGenerate وPreview وRelease وCollect وعدد التسليمات 3 وGenerate Feedback وRelease Feedback](https://arabroot.io/content/images/2026/10/jupyterhub-education-05-formgrader.webp)

واجهة formgrader مفتوحة مباشرة على المسار /user/t.sara/formgrader

فتحناها مباشرة على المسار `/user/t.sara/formgrader` في تبويب مستقل، والسبب أن [دليل nbgrader مع JupyterHub](https://nbgrader.readthedocs.io/en/stable/configuration/jupyterhub%5Fconfig.html?ref=arabroot.io) ينبه إلى أن JupyterHub منذ الإصدار 4.1 يمنع عرض الصفحات داخل إطار iframe لأسباب أمنية، وواجهة التصحيح تعرض داخل إطار في JupyterLab فتظهر فارغة، والحلول التي يذكرها الدليل: فتحها في تبويب جديد، أو تفعيل النطاقات الفرعية Subdomains في JupyterHub.

## ثلاث نقاط أمان قبل أول مقرر حقيقي

### كود الطالب يعمل بحساب المعلم

هذا هو سبب السطر الإضافي في إجابة الطالب الثالث، فقد كتب في الملف `/tmp/who-ran-my-code.txt` اسم المستخدم الذي شغل كوده، وبعد التصحيح كان محتوى الملف كما يلي:

```text
-rw-rw-r-- 1 t.sara t.sara 49 Oct 22 01:11 /tmp/who-ran-my-code.txt
t.sara /home/t.sara/py101/autograded/s2026003/hw1
```

أي أن الأمر `autograde` شغل كود الطالب بحساب المعلمة `t.sara` وداخل مجلدها، وهذا يعني أن طالباً يكتب في إجابته سطراً يقرأ ملف `gradebook.db` ويغير درجته، أو يقرأ إجابات زملائه من مجلد `submitted`، أو يحذف ملفات المعلم، سوف ينجح كوده لحظة التصحيح. و[الأسئلة الشائعة في nbgrader](https://nbgrader.readthedocs.io/en/stable/user%5Fguide/faq.html?ref=arabroot.io) تقول صراحة إن الأداة لا تحمي من الكود غير الآمن بشكل مباشر، وتحيل إلى [التصحيح داخل Docker Container](https://nbgrader.readthedocs.io/en/stable/user%5Fguide/advanced.html?ref=arabroot.io) عبر نواة معزولة. لذلك لا تقم بتشغيل `autograde` بحساب المعلم على سيرفر فيه بيانات مهمة، والأنسب أن يكون التصحيح بحساب مخصص لا يملك إلا مجلد المقرر، أو داخل Container لكل تصحيح.

### حد الذاكرة الذي لا يطبق

في الإعداد كتبنا `c.Spawner.mem_limit = "512M"`، فهل يمنع ذلك طالباً من استهلاك ذاكرة السيرفر كلها؟ جربنا من حساب الطالب الأول:

```bash
su - s2026001 -c 'p=$(pgrep -u s2026001 -f singleuser | head -1); tr "\0" "\n" < /proc/$p/environ | grep -E "^MEM_LIMIT"
/srv/venv/bin/python3 -c "
b = bytearray(900 * 1024 * 1024)
import resource; print(\"allocated 900 MB, peak RSS\", resource.getrusage(resource.RUSAGE_SELF).ru_maxrss // 1024, \"MB\")"'
```

```text
MEM_LIMIT=536870912
allocated 900 MB, peak RSS 910 MB
```

الحد وصل إلى سيرفر الطالب كمتغير بيئة Environment Variable فقط، والعملية استهلكت 910MB دون أي اعتراض. و[مرجع ال Spawner](https://jupyterhub.readthedocs.io/en/stable/reference/api/spawner.html?ref=arabroot.io) يقول ذلك صراحة: الحد يحتاج Spawner يطبقه، والمشغل الافتراضي LocalProcessSpawner لا يطبقه. فإذا كان السيرفر مشتركاً بين ثلاثين طالباً، فطالب واحد بحلقة تحمل ملف بيانات كبيراً يستطيع أن يوقف المحاضرة للجميع، والحل أن تستخدم طريقة تطبق الحدود فعلاً من الجدول في أول المقال، مثل `limits.memory` في The Littlest JupyterHub أو حدود Kubernetes.

### مجلد التسليم

مجلد التبادل `/srv/nbgrader/exchange` مفتوح للكتابة للجميع، والمجلد الذي تصل إليه التسليمات له الصلاحيات `drwx-ws-wx`، أي يكتب فيه الطالب ولا يستطيع عرض محتواه:

```text
ls: cannot open directory '/srv/nbgrader/exchange/py101/inbound': Permission denied
```

ولكن لاحظ أن مجلد كل تسليم داخله بالصلاحيات `drwxrwxrwx`، وجربنا من حساب الطالب الثاني قراءة ملف تسليم الطالب الأول بمساره الكامل فنجحت القراءة. والذي يحمي التسليم هنا هو أن اسم المجلد فيه الوقت بأجزاء المليون من الثانية ونص عشوائي من 12 حرفاً مثل `jQrwWzFOeYso`، فلا يمكن تخمينه عملياً، ولكنه ليس حماية بالصلاحيات. والقاعدة: اجمع التسليمات بالأمر `collect` بعد الموعد مباشرة، ولا تجعل المجلد على مساحة مشتركة يصل إليها من لا علاقة له بالمقرر، وفي الأنظمة الكبيرة استخدم خدمة تبادل بديلة من [إضافات nbgrader](https://nbgrader.readthedocs.io/en/stable/?ref=arabroot.io).

## النسخ الاحتياطي والترقية

- **ما الذي تنسخه؟** مجلدات الطلاب في `/home`، ومجلد المقرر عند المعلم وفيه `gradebook.db` وكل التسليمات والتصحيحات، وملفي الإعداد، وقاعدة بيانات JupyterHub `jupyterhub.sqlite` في مجلد عمله. ثم طبق ما في [النسخ الاحتياطي بقاعدة 3-2-1](https://arabroot.io/articles/%D8%A7%D9%84%D9%86%D8%B3%D8%AE-%D8%A7%D9%84%D8%A7%D8%AD%D8%AA%D9%8A%D8%A7%D8%B7%D9%8A-%D8%A8%D9%82%D8%A7%D8%B9%D8%AF%D8%A9-3-2-1-%D9%88%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1-%D8%A7%D9%84%D8%A7%D8%B3%D8%AA%D8%B9%D8%A7%D8%AF%D8%A9/).
- **الترقية Upgrade:** لا ترق JupyterHub أو JupyterLab أو nbgrader في منتصف الفصل الدراسي، والسبب أن واجهات nbgrader في JupyterLab إضافات Extensions مبنية لإصدار محدد من JupyterLab، فإذا تقدم أحدهما على الآخر قد تتعطل قائمة الواجبات عند كل الطلاب في يوم التسليم. فرق بين الفصول، وجرب دورة واجب كاملة كالتي في هذا المقال قبل أن تفتح السيرفر للطلاب.

## مشكلات شائعة وحلولها

| ما تراه                                                        | السبب                                        | الحل                                                         |
| -------------------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------ |
| السيرفر لا يبدأ، وفي السجل «increase Spawner.http\_timeout»    | السيرفر لم يرد خلال 30 ثانية                 | ارفع c.Spawner.http\_timeout وأضف ذاكرة ومعالجاً للسيرفر     |
| ModuleNotFoundError: No module named 'nbformat' في سطر الأوامر | Python النظام وليس بيئة JupyterHub           | أضف /srv/venv/bin إلى PATH أو استخدم الطرفية داخل JupyterLab |
| واجهة formgrader فارغة داخل JupyterLab                         | JupyterHub 4.1 وما بعده يمنع الإطارات iframe | افتحها في تبويب مستقل أو فعل النطاقات الفرعية                |
| تحذير checksum عند autograde                                   | الاختبارات المخفية غير موجودة في نسخة الطالب | متوقع، ويعيد nbgrader الخلايا المقفلة من المصدر              |
| طالب واحد يبطئ السيرفر للجميع                                  | LocalProcessSpawner لا يطبق حد الذاكرة       | The Littlest JupyterHub أو DockerSpawner أو Kubernetes       |
| الاسم والبريد فارغان في grades.csv                             | الطلاب غير مضافين إلى قاعدة الدرجات          | nbgrader db student import قبل أول واجب                      |

## تنظيف المختبر

```bash
docker compose down -v
docker image rm edu3-jhub:6.1.1
```

## تدريب عملي

- أضف إلى الواجب سؤالاً ثالثاً يقرأ ملف CSV بالمكتبة pandas ويحسب متوسط كل مادة، مع اختبار مخفي لملف فيه قيمة فارغة.
- أنشئ حساب Linux اسمه `grader` لا يملك إلا مجلد المقرر، وشغل `autograde` به، ثم أعد تسليم الطالب الثالث وانظر من أصبح يشغل الكود.

## الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

- JupyterHub يعطي كل طالب سيرفر Jupyter خاصاً به في المتصفح، وكل سيرفر استهلك في تجربتنا نحو 168MB وهو خامل، فخطط على عدد الطلاب في المحاضرة نفسها.
- المشغل Spawner هو القرار الأهم: الافتراضي لا يطبق حد الذاكرة، وقد استهلك طالب 910MB رغم حد 512MB، فاختر The Littlest JupyterHub أو Docker أو Kubernetes لأي مقرر حقيقي.
- nbgrader يغطي دورة الواجب كاملة، والاختبارات المخفية هي التي كشفت خطأ الحدود الذي اجتاز الاختبارات الظاهرة.
- التصحيح الآلي يشغل كود الطالب بحساب المعلم وداخل مجلده، فاعزل التصحيح في حساب مخصص أو Container قبل أول مقرر.
- جرب بداية المحاضرة قبل أن تبدأ، فقد فشلت ثلاثة سيرفرات بدأت معاً بالمهلة الافتراضية 30 ثانية.

## سجل التحديثات

- أكتوبر 2026: كتابة المقال وتجربته على JupyterHub 6.1.1 وJupyterLab 4.6.4 وnbgrader 0.9.6.