# PRD — doh_moodle

Evidence date: 2026-10-16.

Source: `git@bitbucket.org:overdrive/doh_moodle.git`, `main` at `b9db23cf` (2026-06-24).

There is no prior PRD in the source repository or in Silicon Ark. The root `README.md` is the upstream Moodle readme. Requirements below are recovered from Overdrive plugins, their READMEs, and the code those READMEs describe. Provenance is labelled. Committed secrets are not reproduced.

Moodle core behaviour (login, course creation, gradebook maths, assignment submission) is upstream Moodle 4.5.7+. This PRD specifies the behaviour this repository adds or wires. It does not restate the Moodle user guide.

## Product purpose

**Implemented.** The site is Moodle 4.5.7+ (`version.php` release `4.5.7+ (Build: 20251009)`).

**Documented (plugin READMEs).** `local_gibbon_cohort` synchronises cohorts, members, and cohort enrolments from a Gibbon database. `local_gibbon_sync` provides web services so Gibbon can read students and grades. `local_course_startdate` blocks student access before the course start date. `plagiarism_azureai` checks submission text through a configured HTTP API.

**Inferred.** The repository is the Moodle half of the Gibbon integration described in Silicon Ark `doh_sims`. The institution, production URL, and whether "doh" means Department of Health in this repo are **Unknown / Requires confirmation**. This tree does not state them.

## Users and roles

| Actor | What they do in this tree | Status |
|---|---|---|
| Student | Uses Moodle courses. Sees "My credits" when logged in and not a guest. Cannot open a course before its effective start date unless they hold a bypass capability | **Implemented** |
| Teacher / editing teacher / staff with course update, manage activities, or view hidden courses | Bypass the start-date block in that course | **Implemented** in `local_course_startdate\course_access` |
| Site administrator | Bypasses the start-date block. Configures the local plugins. Can open Moodle Adminer if that plugin is installed | **Implemented** (`is_siteadmin` in course access; Adminer README) |
| Manager | Holds `local/gibbon_cohort:manage` at system context | **Implemented** in `db/access.php`. No page in the plugin was found that checks this capability |
| Gibbon (external system) | Calls Moodle REST with a web-service token and the plugin API key. Reads students, grades, and courses. May post feedback onto an existing grade row | **Implemented** |
| Moodle cron | Runs `local_gibbon_cohort\task\sync_all` every 5 minutes, and runs plagiarism adhoc tasks | **Implemented** |
| Deploy operator | Azure Pipelines on `main` or `releases/*` builds a tarball and SSHes it to the Moodle host | **Implemented** as pipeline YAML. The operator is not a Moodle role |

Default class-role map (admin setting `rolemapping`, overridable):

| Gibbon role string | Moodle role shortname |
|---|---|
| Teacher | editingteacher |
| Lecturer | editingteacher |
| Student | student |
| Assistant | teacher |

A Gibbon role that is not in the map uses the plugin default enrol role, which defaults to the student archetype. **Implemented** in `sync_cohort_enrolments.php`.

Guest users do not get the My credits link. **Implemented** in `hook_callbacks.php`.

## Systems the product talks to

1. The learner's browser, loading Moodle.
2. The Moodle database (MariaDB in the committed local config sample).
3. A Gibbon database, read by `local_gibbon_cohort` using the plugin's own connection settings (type, host, name, user, password). Direction: Moodle reads SQL views. Moodle does not write to Gibbon from this plugin.
4. Gibbon as an HTTP client of Moodle web services. Direction: Gibbon calls Moodle. The client in `doh_sims` is LMS Sync. A second client is stored at `gibbon_module/MoodleSync/` in this repo.
5. A plagiarism HTTP API chosen in plugin settings (language strings say an Azure Function; the settings page also knows GPTZero and check-plagiarism.com defaults).
6. Azure DevOps and an SSH host named by the pipeline (`ssh-moodle`, app name `moodle`).
7. SonarQube, from Bitbucket Pipelines, project key in `sonar-project.properties`.

`local_gibbon_sync` admin settings store a Gibbon URL ("for future webhook support") and optional Gibbon database credentials. No PHP under `local/gibbon_sync/` other than `settings.php` and the language file reads those values. **Implemented** as unused settings.

## Major workflows

### WF-1 — Cron copies Gibbon classes into Moodle

Trigger: Moodle scheduled task `\local_gibbon_cohort\task\sync_all`, cron `*/5` (every 5 minutes). **Implemented** in `db/tasks.php`.

The README still describes three separate tasks (cohorts hourly, members 5 minutes later, enrolments 10 minutes later) and gives CLI examples for those class names. Those classes still exist and can be executed, but they are not registered in `db/tasks.php`. The running schedule is the one consolidated task. **Documented requirement disagrees with implemented schedule.**

Order inside `sync_all::execute()`:

1. Sync cohorts, only if `synccohorts` is on (default on).
2. Sync credits and year groups. This step is not behind a checkbox.
3. Sync cohort members, only if `synccohortmembers` is on (default on).
4. Sync cohort enrolments, only if `synccohortenrolments` is on (default on).
5. Set course group modes from the `enrol_database` new-course table. This step is not behind a checkbox. It returns immediately when that enrol plugin's new-course table or required fields are empty.

On exception the task rethrows so Moodle marks the task failed. Memory is raised to `MEMORY_HUGE`.

Match keys (**Documented** in the README and **Implemented** in the tasks):

| Step | Gibbon source (default view name) | Match | Moodle target |
|---|---|---|---|
| 1 | `moodleCohort` | `idnumber` | `cohort`, `component = local_gibbon_cohort` |
| 1b | `moodleCohortCourseCredits` | cohort `idnumber` = view `cohortid` | `local_gibbon_cohort_credits` |
| 1b | `moodleYearGroupCredits` | `gibbonYearGroupID` | `local_gibbon_cohort_yeargroup` |
| 1b | `moodleCourse` | `gibbonCourseID` = course `idnumber`; `gibbonYearGroupIDList` split on commas | `local_gibbon_cohort_course_yeargroup` |
| 2 | `moodleCohortMember` | cohort by idnumber; user by `username` | `cohort_members` |
| 3 | `moodleCohortCourse` | cohort by idnumber; course by `gibbonCourseID` = course `idnumber` | `enrol` method `cohort` |
| 4 | `enrol_database` new-course table | course `idnumber`, else `shortname` | course and module group mode |

Moodle course `idnumber` must equal the Gibbon course id or enrolment linking does not find the course. **Documented** and **Implemented**.

### WF-2 — A learner is added, kept, or removed from a class

**Implemented** in `sync_cohort_members.php`.

- A row with an empty cohort id or username is skipped.
- A username that does not exist in Moodle is counted as user not found. The plugin does not create users.
- A user who is in the view is added to the Moodle cohort.
- A user who is absent from the view is removed only when External database authentication is enabled and its "Remove user" value is full delete (`AUTH_REMOVEUSER_FULLDELETE`). If auth_db is off, or the value is unset, keep, or suspend, the user stays in the cohort.
- A suspended Moodle user is never removed by this sync, including when full delete is on.

The README section "Cohort member removal and auth_db" matches this code. An earlier sentence in the same README ("Removes users from cohorts if they're no longer in external database") does not. The code and the later section are the behaviour.

### WF-3 — Enrolment, groups, and course roles

**Implemented** in `sync_cohort_enrolments.php` and `course_group_mode_sync.php`.

- Creates a cohort enrolment instance linking the cohort to the course.
- When "Create groups" is on, creates a course group for the cohort and backfills a group when an existing instance has none or points at a missing group. The checkbox default is off.
- Assigns a course role from the Gibbon role string via `rolemapping`. Unmapped roles use the default role.
- Removes role assignments that belong to this cohort enrolment instance (`component = enrol_cohort` and `itemid` = that enrol id) when they are not the target role.
- Removes role assignments whose `component` is empty (manually assigned at course context) for users enrolled through that cohort instance.
- Leaves role assignments from other components (`enrol_manual`, `enrol_self`, and any other non-empty component) in place.
- Sets the course to separate groups with force group mode, and sets each activity module in that course to separate groups, for courses reached by the group-mode step. `course_group_mode_sync.php` writes `SEPARATEGROUPS` and `groupmodeforce = 1`.

Orphan cohorts and orphan cohort-enrolment instances are removed only when `cohort.component = local_gibbon_cohort`. **Documented** and present in the sync task SQL filters.

### WF-4 — Credits are awarded

**Implemented.**

When a course-total grade is saved (`\core\event\user_graded` and `grade_item.itemtype = course`), the observer looks up the user's cohort enrolment on that course. If several cohort enrolments exist it uses `MIN(enrol.customint1)`. If that cohort has a credits row and `finalgrade >= pass_mark`, it stores that cohort's theoretical credits, clinical credits, and core flag on `local_gibbon_cohort_user_credits`. Otherwise it stores zeros. Activity grades do not award credits.

The same comparison runs again for every user enrolled via that cohort when the synced pass mark or credit values change (`credits_sync::recalc_user_credits_for_cohort`).

Year-group rows and course-year-group rows that disappear from the Gibbon views are deleted. No delete of stale `local_gibbon_cohort_credits` rows was found in `credits_sync.php`.

### WF-5 — Learner opens My credits

Trigger: primary navigation or user menu, URL `/local/gibbon_cohort/my_credits.php`.

Requires login. The page queries `local_gibbon_cohort_user_credits` for `$USER->id` only. There is no userid parameter. It lists each course (linked when the user is enrolled), theoretical credits, clinical credits, and core yes/no, plus totals. **Implemented**.

The page does not compare totals with `local_gibbon_cohort_yeargroup` minimums. Year-group minimums are stored. A "has the learner met the year-group minimum?" screen was not found.

### WF-6 — Gibbon pulls students and grades

Trigger: HTTP to `/webservice/rest/server.php` with `wstoken`, `wsfunction`, and `api_key`.

Service name: **Gibbon Sync Service**, shortname `gibbon_sync`, declared enabled, `restrictedusers = 0`. **Implemented** in `db/services.php`. A site admin can disable it after install. Live state is **Unknown**.

Functions:

| Function | Type | Behaviour |
|---|---|---|
| `local_gibbon_sync_get_students` | read | Active enrolments (`user.deleted = 0`, `user.suspended = 0`, `user_enrolments.status = 0`, course id > 1). Optional `course_id` and `last_sync` (filters `user.timemodified`). Returns distinct users. A call without `course_id` does not say which course each user is in |
| `local_gibbon_sync_get_grades` | read | Requires `course_id`. Grade items of type `mod` or `course` with a non-null `finalgrade`. Optional `user_id`, `activity_type` (matches `grade_items.itemmodule`), `last_sync`. Includes `passmark` (`gradepass`) and, when the credits table exists, theoretical credits, clinical credits, and `is_core` (else 0). Does not exclude suspended users |
| `local_gibbon_sync_get_courses` | read | Visible courses the token user can `moodle/course:view`. Includes `idnumber` |
| `local_gibbon_sync_get_course` | read | One course by id. Same API-key check pattern as the others |
| `local_gibbon_sync_sync_grades` | write | For each `grade_id`, if the `grade_grades` row exists and `feedback` was sent, updates feedback and `timemodified`. Does not change the numeric grade |

Every function rejects the call when the stored API key is empty or does not match. Message: `Invalid API key`. **Implemented**.

The README calls `sync_grades` bidirectional grade sync. The code updates feedback only.

`capabilities` on these functions is an empty string. The token user still has to be allowed to use the web service. `get_courses` additionally checks `moodle/course:view`. `get_students` and `get_grades` do not check a course capability in the method body.

Silicon Ark `doh_sims` describes `api_key` as optional. In this repository the key is required. **Conflict between the two knowledge sets.** The Moodle code is the behaviour of this product.

### WF-7 — Student opens a course before it starts

**Implemented** in `local_course_startdate`.

Plugin enabled by default (`enabled` default 1). Grace period default on (`usegraceperiod` default 1).

When enabled, `after_require_login` and the course-list filter call `student_can_see_course`:

- Site course is always visible.
- Site admins, and users with `moodle/course:update`, `moodle/course:manageactivities`, or `moodle/course:viewhiddencourses` in the course, always see it.
- A course with an empty start date is treated as already started.
- If grace is on, the effective start is Moodle's `course_classify_start_date` (the site "Grace period for future courses" setting). If grace is off, the effective start is `course.startdate`.
- Before that timestamp the student is redirected to `/my/courses.php` with an info notification. If redirect is prevented, a `moodle_exception` is thrown.
- Enrolment is not removed.

PHPUnit coverage is `local/course_startdate/tests/course_access_test.php`.

### WF-8 — Plagiarism check on submit

**Implemented** for assignments when the plugin is enabled globally and the course module setting `use` is 1.

Observers:

- `assignsubmission_onlinetext` submission created and updated
- `assignsubmission_file` submission created and updated
- `mod_quiz\event\attempt_submitted`

The assignment form adds the controls only when the module is `assign` (enable, show report to students default on, threshold 10–100% in steps of 10). The quiz observer reads the same per-module `use` flag. The settings form does not add that control for a quiz. Whether a quiz can be turned on is **Requires confirmation**.

The check is an adhoc task `plagiarism_azureai\task\process_queue`. It stores status `processing`, then `failed` or a parsed result, on `plagiarism_azureai_results`. It sends `submissiontext`. `db/tasks.php` registers no scheduled task. `version.php` still sets legacy `$plugin->cron = 300`.

Provider comes from `api_provider` (default `azure`). Endpoint and key are site settings. The Azure branch of the settings default leaves the endpoint empty; GPTZero and check-plagiarism.com have built-in default URLs. **Implemented** in `settings.php`.

## Functional requirements

### FR-001 — Cohort sync schedule

The system runs one scheduled task every 5 minutes that performs cohort, credit, member, enrolment, and group-mode steps in that order.

- Actor: cron
- Inputs: plugin settings and Gibbon views
- Output: Moodle cohort, enrol, group, and credit rows; PHP error log lines prefixed `local_gibbon_cohort`
- Failure: connection or query failure is traced and, for an exception in `sync_all`, the task fails
- Evidence: `db/tasks.php`, `classes/task/sync_all.php`
- Implementation: **Implemented**
- Confidence: High

### FR-002 — Course idnumber is the Gibbon course id

Cohort enrolment and course-year-group linking resolve the Moodle course by `course.idnumber`.

- Evidence: README; enrolment task; `credits_sync` course-year-group step
- Implementation: **Implemented**
- Confidence: High

### FR-003 — Credits follow the course total and the pass mark

Theoretical and clinical credits for a user and course are the cohort's credits when the course-total final grade is greater than or equal to that cohort's pass mark, and zero otherwise.

- Evidence: `event/observer.php`, `credits_sync::recalc_user_credits_for_cohort`
- Implementation: **Implemented**
- Confidence: High

### FR-004 — My credits shows only the current user

- Evidence: `my_credits.php` (no userid argument; `require_login`)
- Implementation: **Implemented**
- Confidence: High

### FR-005 — Web service API key

Gibbon sync external functions refuse the call when `local_gibbon_sync/api_key` is empty or different from `api_key`.

- Evidence: `classes/api/grades.php`, `classes/external/get_courses.php`
- Implementation: **Implemented**
- Confidence: High

### FR-006 — Grade write-back is feedback only

`local_gibbon_sync_sync_grades` updates `grade_grades.feedback` for an existing grade id. It does not set `finalgrade`.

- Evidence: `grades.php` `sync_grades`
- The README's "bidirectional grade sync" wording is broader than the code
- Implementation: **Implemented** as feedback update
- Confidence: High

### FR-007 — Students do not enter a course before the effective start

Enrolment stays. Lists and `require_login` hide or redirect.

- Evidence: `local/course_startdate/`
- Implementation: **Implemented**
- Confidence: High

### FR-008 — Assignment plagiarism queue

An assignment submission or update queues a check only when the plugin is globally enabled and the module `use` flag is 1.

- Evidence: `plagiarism/azureai/classes/event/observer.php`
- Implementation: **Implemented**
- Confidence: High

## Integrations

See `technical-documentation.md` for endpoints and tables. Product-level contracts:

| Integration | Direction | Trigger | Auth observed in this repo |
|---|---|---|---|
| Gibbon SQL views | Gibbon → Moodle (Moodle pulls) | `sync_all` every 5 minutes | DB user/password in plugin settings |
| Moodle REST Gibbon Sync Service | Gibbon → Moodle | Gibbon cron or manual sync, not in this process | Web-service token plus API key |
| Plagiarism HTTP API | Moodle → provider | Adhoc task after submit | API key in plugin config |
| Azure SSH deploy | Pipeline → host | Push to `main` or `releases/*`, PRs to `main` | Azure service connection `ssh-moodle` |
| SonarQube | Bitbucket → Sonar | Push to `main` | `SONAR_TOKEN` repository variable |

Failure behaviour for the Gibbon DB connection is a trace line and a return from that step, not a retry loop in the code that was read. Web-service failures are Moodle's normal exception responses (`Invalid API key`, missing token, and so on). Plagiarism API failure sets the result row to `failed` and stores `errorresponse`.

## Constraints

- GPL-3.0-or-later, upstream Moodle.
- PHP 8.1+ from `composer.json`. The Gibbon plugins declare `requires` Moodle 4.0 (`local_gibbon_sync`, `2022041900`) or 4.1 (`local_gibbon_cohort` and course start date, `2022111800`). The tree itself is 4.5.7+.
- Both Gibbon plugins are marked alpha.
- Course `idnumber` uniqueness is a Moodle convention this sync depends on. This plugin does not add a unique index.
- `local/gibbon_cohort:manage` is defined and not checked by the pages that were read.
- Table `gibbon_sync_course_mapping` is created by `local/gibbon_sync/db/install.xml`. No PHP in the tree reads or writes it.

## Assumptions and conflicts

| Item | Label |
|---|---|
| This site is the Moodle paired with `doh_sims` | **Inferred**. Requires confirmation of the live URL |
| README schedule of three offset tasks | **Documented**. **Not** what `db/tasks.php` registers |
| README bidirectional grades | **Documented**. Code writes feedback only |
| `doh_sims` "api_key optional" | Conflicts with this code, which requires the key |
| `gibbon_module/MoodleSync` is what production runs | **Unknown**. `doh_sims` documents LMS Sync as the client |
| Institution / Department of Health name | **Unknown** in this repository |
| `configx.php` localhost values are production | They are not. They are a local sample that includes credentials |
