Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Enforce LF line endings for all text files.
* text=auto eol=lf

# Binary files — no conversion.
*.png binary
*.jpg binary
*.gif binary
*.ico binary
*.woff binary
*.woff2 binary
*.ttf binary
*.eot binary
*.min.js binary
*.min.css binary

# Exclude developer artefacts from release archives.
.github/ export-ignore
docs/ export-ignore
tools/ export-ignore
makefile export-ignore
CHANGELOG.md export-ignore
.gitattributes export-ignore
.gitignore export-ignore
.phpcsignore export-ignore
.phpcs.xml export-ignore
53 changes: 51 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,53 @@
/vue3/node_modules/
/vue3/coverage
# OS artefacts
.DS_Store
Thumbs.db
desktop.ini
*Zone.Identifier
._*

# IDE
.idea/
.vscode/
*.iml
*.iws

# Node
node_modules/
npm-debug.log*
yarn-error.log*
package-lock.json
yarn.lock

# vue
/vue3/node_modules/
/vue3/coverage

# PHP / Composer
vendor/
composer.lock

# AMD build artefacts (keep src, ignore build output)
amd/build/

# moodle-plugin-ci working directories
ci/
moodle/

# PHPUnit / coverage
.phpunit.result.cache
.phpunit.cache/
coverage/
*.clover

# Behat screenshots
behatfaildumps/

# Temporary / backup files
*.bak
*.tmp
*.swp
*~

# Local developer overrides
.env
.env.local
91 changes: 67 additions & 24 deletions classes/completion.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@
declare(strict_types=1);

namespace local_adele;

use core\event\course_completed;
use local_adele\helper\user_path_relation;
use context_system;
use local_adele\event\user_path_updated;
use local_adele\learning_path_update;

/**
* External Service for local adele.
Expand All @@ -40,35 +41,77 @@
*/
class completion {
/**
* Observer for course completed
* Observer for the core course_completed event.
*
* When a Moodle course is completed, every active learning path of the
* affected student that references this course in one of its nodes has to be
* re-evaluated so that the corresponding node-completion criterion can turn
* the node into "completed".
*
* @param object $event
* Important: on \core\event\course_completed the affected student is carried
* in $event->relateduserid. $event->userid is the ACTOR that caused the
* completion (a grading teacher, an administrator or - most commonly - the
* cron/system process aggregating the completion criteria) and must NOT be
* used to look up the learning path owner.
*
* @param course_completed $event The Moodle course_completed event.
* @return void
*/
public static function completed($event) {
// The course_completed event carries the actor in userid and the affected student
// in relateduserid. Route by the student, otherwise a teacher- or cron-triggered
// completion recomputes the wrong user's paths (or none) and the node never
// completes (#495).
public static function completed(course_completed $event): void {
// The student whose course was completed is represented by relateduser, not userid.
$userid = (int) $event->relateduserid;
$courseid = (int) $event->courseid;

// Nothing sensible to do without both a student and a course.
if ($userid <= 0 || $courseid <= 0) {
return;
}

$userpathrelation = new user_path_relation();
$learningpaths = $userpathrelation->get_learning_paths($event->relateduserid);
if (!$learningpaths) {
$learningpaths = $userpathrelation->get_learning_paths($userid);

if (empty($learningpaths)) {
return;
}

foreach ($learningpaths as $learningpath) {
$learningpath->json = json_decode($learningpath->json, true);
foreach ($learningpath->json['tree']['nodes'] as $node) {
if (is_array($node['data']['course_node_id']) && in_array($event->courseid, $node['data']['course_node_id'])) {
// Recompute the path once per event, even if the course maps to several nodes.
$eventsingle = user_path_updated::create([
'objectid' => $learningpath->id,
'context' => context_system::instance(),
'other' => [
'userpath' => $learningpath,
],
]);
$eventsingle->trigger();
break;
$pathjson = json_decode($learningpath->json, true);

// A single malformed or incomplete snapshot must not abort the
// processing of the remaining (valid) learning paths.
if (
!is_array($pathjson) ||
empty($pathjson['tree']['nodes']) ||
!is_array($pathjson['tree']['nodes'])
) {
continue;
}

foreach ($pathjson['tree']['nodes'] as $node) {
$courseids = $node['data']['course_node_id'] ?? [];

if (!is_array($courseids)) {
$courseids = [$courseids];
}

// The stored course_node_id values may be strings or integers
// depending on the JSON origin - normalise both sides for a
// type-safe match.
$courseids = array_map('intval', $courseids);

if (!in_array($courseid, $courseids, true)) {
continue;
}

// Hand the decoded snapshot to the central update service, which
// re-evaluates every node of the path against the current state.
$learningpath->json = $pathjson;
learning_path_update::trigger_user_path_update($learningpath);

// The recompute already covers all nodes of this path, so a
// single trigger per learning path is sufficient even if the
// course is referenced by more than one node.
break;
}
}
}
Expand Down
32 changes: 30 additions & 2 deletions classes/course_completion/conditions/course_completed.php
Original file line number Diff line number Diff line change
Expand Up @@ -163,16 +163,27 @@ public function get_completion_status($node, $userid) {
if ($course->enablecompletion) {
// Get the course completion instance.
$completion = new completion_info($course);
$progress = progress::get_course_progress_percentage($course, $userid) ?? 0;
if ($progress !== null) {
$progress = progress::get_course_progress_percentage($course, $userid);
// Ticket #502: "in progress" starts at the first course access
// (erster Kursaufruf), not at mere enrolment. A learner who is
// only enrolled but has never opened the course is NOT started.
// The timed processing-duration restriction is unaffected; it
// still keys off the node's first unlock.
if ($this->has_accessed_course((int) $course->id, (int) $userid)) {
$isinbetween = true;
}
$progress ??= 0;
// Check if the user has completed the course.
$coursecompleted = $completion->is_course_complete($userid);
if ($coursecompleted) {
$progress = 100;
$completed = true;
$finished++;
// A completed course cannot have stayed untouched, so it also
// counts as started. This keeps partially completed
// multi-course nodes (some done, not enough yet) in the
// inbetween state.
$isinbetween = true;
}
$progresses[] = $progress;
// Ticket #464 H4: this list is rendered via v-html in the node feedback, so the
Expand Down Expand Up @@ -248,6 +259,23 @@ public function get_completion_status($node, $userid) {
return $coursecompletion;
}

/**
* Whether the user has ever accessed the given course (first course view).
*
* Ticket #502: this is the "started" (Bearbeitungsbeginn) criterion for the
* progress display. Moodle records a row in user_lastaccess the first time a
* user accesses a course, so the presence of that row means the course has
* been opened at least once.
*
* @param int $courseid
* @param int $userid
* @return bool
*/
private function has_accessed_course(int $courseid, int $userid) {
global $DB;
return $DB->record_exists('user_lastaccess', ['courseid' => $courseid, 'userid' => $userid]);
}

/**
* Get the average of the furthest nodes.
* @param array $progresses
Expand Down
20 changes: 17 additions & 3 deletions classes/enrollment.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ public static function subscribe_user_to_learning_path($learningpath, $params, $
}
$userpath = self::buildsqlqueryuserpath($learningpath->id, $params->relateduserid, $courseid);
if (!$userpath) {
$id = $DB->insert_record('local_adele_path_user', [
$newrecord = [
'user_id' => $params->relateduserid,
'course_id' => $courseid,
'learning_path_id' => $learningpath->id,
Expand All @@ -86,8 +86,22 @@ public static function subscribe_user_to_learning_path($learningpath, $params, $
'tree' => $learningpath->json['tree'] ?? ['nodes' => [], 'edges' => []],
'modules' => $learningpath->json['modules'] ?? null,
]),
]);
$userpath = $DB->get_record('local_adele_path_user', ['id' => $id]);
];
try {
$id = $DB->insert_record('local_adele_path_user', $newrecord);
$userpath = $DB->get_record('local_adele_path_user', ['id' => $id]);
} catch (\dml_exception $e) {
// Ticket #501: a concurrent request won the race and inserted
// the row first; the unique index (user_id, course_id,
// learning_path_id) rejected this insert. Reuse the row that
// now exists instead of failing or creating a duplicate. If no
// such row is found the exception was not a duplicate conflict
// and must propagate.
$userpath = self::buildsqlqueryuserpath($learningpath->id, $params->relateduserid, $courseid);
if (!$userpath) {
throw $e;
}
}
}
$userpath->json = json_decode($userpath->json, true);
$eventsingle = user_path_updated::create([
Expand Down
Loading
Loading