User Story: As a student, I want to download a PDF certificate automatically when I complete a course, so I can prove my attendance.
- Certificate must be generated as PDF.
- Must include Student Name, Course Name, and Date.
- Only accessible if "Course Completion" criteria are met.
- Admin should be able to upload a background image.
Generating PDFs in PHP can be resource-intensive. We need to hook into Moodle's completion tracking system and ensure the file is served securely (not publicly accessible).
👨💻 Senior Developer Solution
Architecture: Create a `local_certificate` plugin or a `mod_certificate` activity. Using `mod_certificate` is better for course placement.
Implementation Steps:
- Library: Use Moodle's built-in `TCPDF` wrapper (`$pdf = new pdf();`).
- Access Control: In `view.php`, check `$completion = $info->get_data($cm, true);` before generating.
- File Serving: Use `send_temp_file()` or `pluginfile.php` API to deliver the PDF.
// mod/certificate/view.php
require_once('../../config.php');
require_once($CFG->libdir . '/pdflib.php');
$id = required_param('id', PARAM_INT);
$cm = get_coursemodule_from_id('certificate', $id, 0, false, MUST_EXIST);
$course = $DB->get_record('course', array('id' => $cm->course), '*', MUST_EXIST);
require_login($course, true, $cm);
// Check completion
$completion = new completion_info($course);
if (!$completion->is_course_complete($USER->id)) {
print_error('notcompleted', 'mod_certificate');
}
// Generate PDF
$doc = new pdf();
$doc->AddPage();
$doc->SetFont('helvetica', 'B', 20);
$doc->Write(0, 'Certificate of Completion');
$doc->Ln(20);
$doc->SetFont('helvetica', '', 14);
$doc->Write(0, 'This is to certify that ' . fullname($USER) . ' has completed the course.');
$doc->Output('certificate.pdf', 'D');
Optimization: Cache the generated PDF in Moodle's `filedir` so it doesn't regenerate on every click. Use Adhoc Task to email it upon completion event `\core\event\course_completed`.
User Story: As a university admin, I want students to see their final grades only if they have paid their tuition fees.
- Integrate with external Payment Gateway (Stripe/PayPal).
- If unpaid, the Gradebook view should show a "Payment Required" message.
- Payment status is stored in a custom user profile field or external DB.
Moodle's Gradebook is core functionality. Modifying core code is forbidden. We need a non-intrusive way to intercept the grade display.
👨💻 Senior Developer Solution
Architecture: We cannot easily "hide" the gradebook without core hacks. The best approach is a Report Plugin or a Theme Override, but the most robust way is a Core Report Callback or restricting access to the course itself. However, for *just* grades:
Better Approach: Use a local plugin that subscribes to the event `\core\event\user_graded` (for notifications) or intercepting the UI via a Theme Renderer Override for the grade report.
Solution: Override the Grade Report Renderer
// theme/university/renderers.php
namespace theme_university\output;
use core_grades\output\general_action_bar;
class core_grades_renderer extends general_action_bar {
public function render_grade_report($report) {
global $USER, $DB;
// Check custom profile field for payment status
// Assuming fieldid 5 is 'paid_status'
$has_paid = $DB->get_field('user_info_data', 'data', ['userid'=>$USER->id, 'fieldid'=>5]);
if ($has_paid !== '1') {
return $this->output->notification('🔒 Grades are locked until tuition fees are settled.', 'warning');
}
return parent::render_grade_report($report);
}
}
Note: This is tricky. A safer backend approach is to use Availability API. Create a custom availability condition `availability_payment`. Teachers add this restriction to the "Final Exam" or the Course Section containing grades.
User Story: Students must complete the "End of Course Survey" before they can view their final grade or download their certificate.
- Survey is a Moodle "Feedback" activity.
- Grades/Certificate activity must be hidden/unavailable until Feedback is submitted.
Standard Moodle "Restrict Access" allows unlocking based on activity completion. This is a configuration task, but if we need a custom logic (e.g., "Survey from external system"), we need code.
👨💻 Senior Developer Solution
Architecture: Use Moodle's Availability API (`availability_custom`).
Implementation:
- If using standard Feedback module: No code needed! Set "Restrict Access" on the Certificate: "Must match: Activity Completion 'Course Feedback' is marked complete".
- If using External Survey Tool (e.g., SurveyMonkey): Build a custom availability plugin `availability_externalsurvey`.
// availability/condition/externalsurvey/classes/condition.php
namespace availability_externalsurvey;
class condition extends \core_availability\condition {
public function is_available($not, \core_availability\info $info, $grabthelot, $userid) {
// Check Moodle Cache first
$cache = \cache::make('availability_externalsurvey', 'status');
$status = $cache->get($userid);
if ($status === false) {
// Call External API
$status = \local_integration\api::check_survey_status($userid, $this->courseid);
$cache->set($userid, $status);
}
return $status === 'completed';
}
public function get_description($full, $not, \core_availability\info $info) {
return "You must complete the external survey to access this.";
}
}
Optimization: Cache the external API response in MUC (Moodle Universal Cache) to avoid calling the SurveyMonkey API on every page load.
User Story: Students upload raw video files (MOV, AVI). The system must convert them to MP4 (H.264) for web playback automatically.
- Support large file uploads (up to 2GB).
- Background processing (don't make user wait).
- Notify user when processing is done.
PHP cannot handle video conversion efficiently. We need `ffmpeg` and asynchronous processing.
👨💻 Senior Developer Solution
Architecture: Use Adhoc Tasks (Moodle's Job Queue). Never transcode during the HTTP request.
Implementation Plan:
- Upload: User uploads file to `mod_assign`.
- Event Observer: Listen for `\mod_assign\event\assessable_submitted`.
- Queue Task: Create `\local_video\task\transcode_video` and push to queue.
- Worker: The cron job picks up the task, runs `ffmpeg` via `exec()` or sends to AWS MediaConvert.
// local/video/classes/task/transcode_video.php
namespace local_video\task;
class transcode_video extends \core\task\adhoc_task {
public function execute() {
global $DB;
$data = $this->get_custom_data();
$fileid = $data->fileid;
// Get file from Moodle File API
$fs = get_file_storage();
$file = $fs->get_file_by_id($fileid);
if (!$file) {
return;
}
$input = $file->copy_content_to_temp();
$output = $input . '_converted.mp4';
// Run FFMPEG (ensure it's installed on server)
$cmd = "ffmpeg -i " . escapeshellarg($input) . " -vcodec h264 -acodec aac " . escapeshellarg($output);
exec($cmd, $out, $return);
if ($return === 0) {
// Success: Replace original or save as new file
$file->delete(); // Remove original large file
$record = [
'contextid' => $file->get_contextid(),
'component' => $file->get_component(),
'filearea' => $file->get_filearea(),
'itemid' => $file->get_itemid(),
'filepath' => $file->get_filepath(),
'filename' => 'video.mp4'
];
$fs->create_file_from_pathname($record, $output);
// Notify User
\core\notification::add('Video processed successfully!', 'success');
}
}
}
Optimization: Offload to AWS Lambda or S3 Event Triggers to keep the Moodle server CPU load low.
User Story: Teachers want to click a button "Generate Questions" inside a Quiz, provide a topic, and get 5 multiple-choice questions automatically.
- Integrate with OpenAI API (GPT-4).
- Insert questions directly into the Question Bank.
Integrating external API securely and mapping the JSON response to Moodle's complex Question Bank database structure (`mdl_question`, `mdl_question_answers`).
👨💻 Senior Developer Solution
Architecture: Create a local plugin that adds a UI to the Question Bank or a new Question Import Format.
Implementation:
- UI: Add a button to the Question Bank view using `core_question_bank_plugins`.
- API Call: Send prompt to OpenAI. Request JSON format.
- Parsing: Use `question_import` classes to save data.
// local/aiquiz/classes/generator.php
namespace local_aiquiz;
class generator {
public function generate_questions($topic, $count = 5) {
global $DB;
$prompt = "Create $count Moodle XML format multiple choice questions about '$topic'. Wrap in tags.";
// Call OpenAI (Pseudo-code)
$client = new \local_aiquiz\openai_client(get_config('local_aiquiz', 'apikey'));
$xml_content = $client->completion($prompt);
// Parse XML
$qformat = new \qformat_xml();
$questions = $qformat->readquestions($xml_content);
// Save to Question Bank
foreach ($questions as $q) {
$q->category = 1; // Default category ID
question_save_qtype_options($q);
}
return count($questions);
}
}
Nice to have: Add a "Review" step where the teacher can edit the AI suggestions before saving to DB.
User Story: Students need a visual dashboard block showing their progress across ALL enrolled courses with a circular progress bar.
- Must be a "Block" plugin (`block_progress`).
- Show % complete for each active course.
- Use Chart.js or CSS for visuals.
Calculating progress for all courses on every page load is a performance killer. `completion_info` is heavy.
👨💻 Senior Developer Solution
Architecture: Block Plugin + Caching.
Implementation:
- Data Retrieval: `enrol_get_my_courses()`.
- Calculation: Loop courses, get `completion_info`.
- Caching: Store the result in `MUC` (Session or Application cache) for 1 hour or until `\core\event\course_completion_updated` is fired.
// blocks/myprogress/block_myprogress.php
class block_myprogress extends block_base {
public function get_content() {
global $USER;
if ($this->content !== null) {
return $this->content;
}
$this->content = new stdClass;
// Try to get from Cache first
$cache = \cache::make('block_myprogress', 'userprogress');
$data = $cache->get($USER->id);
if (!$data) {
$courses = enrol_get_my_courses();
$data = [];
foreach ($courses as $c) {
$info = new \completion_info($c);
if ($info->is_enabled()) {
$data[] = [
'name' => $c->fullname,
'percent' => $info->get_progress_all()
];
}
}
// Cache for 1 hour
$cache->set($USER->id, $data);
}
$this->content->text = $this->render_chart($data);
return $this->content;
}
private function render_chart($data) {
// Return HTML/JS for Chart.js
return '';
}
}
User Story: When a new employee is added to SAP, they should automatically have an account in Moodle and be enrolled in "Onboarding 101".
- One-way sync: SAP -> Moodle.
- Run nightly.
- Handle updates (name changes) and deletions (suspend user).
Handling thousands of users efficiently. Avoiding "zombie" accounts.
👨💻 Senior Developer Solution
Architecture: Use an Auth Plugin (`auth_sap`) or Enrol Plugin (`enrol_sap`). Best practice: Scheduled Task consuming an API or CSV.
Implementation:
- Scheduled Task: `\auth_sap\task\sync_users`.
- Batch Processing: Don't process one by one. Fetch delta (changes only) if possible.
- User Creation: Use `user_create_user()` core function.
- Enrollment: Use `enrol_manual->enrol_user()`.
// auth/sap/classes/task/sync_users.php
namespace auth_sap\task;
class sync_users extends \core\task\scheduled_task {
public function execute() {
global $DB;
// Fetch from SAP API
$sap_api = new \auth_sap\api_client();
$users = $sap_api->get_new_employees();
foreach ($users as $u) {
// Check if user exists
if (!$existing = $DB->get_record('user', ['username' => $u['id']])) {
$user = new \stdClass();
$user->username = $u['id'];
$user->auth = 'sap';
$user->firstname = $u['first'];
$user->lastname = $u['last'];
$user->email = $u['email'];
$user->confirmed = 1;
try {
$id = user_create_user($user);
// Auto enrol in Onboarding Course (ID: 1)
$enrol = enrol_get_plugin('manual');
$instance = $DB->get_record('enrol', ['courseid'=>1, 'enrol'=>'manual']);
if ($instance) {
$enrol->enrol_user($instance, $id, 5); // 5 = Student role
}
mtrace("Created user $u->id");
} catch (\Exception $e) {
mtrace("Error creating user: " . $e->getMessage());
}
}
}
}
}
Optimization: Use CLI script for large syncs to avoid PHP timeouts. Log every action for audit trails.
User Story: Users want to toggle between Light and Dark mode. The preference should be remembered across sessions.
- Button in the navbar.
- Persist preference in User Preferences (DB).
- Must work with Boost child theme.
CSS variables are easy, but persistence requires AJAX to save state to the backend so it applies when they log in on a different device.
👨💻 Senior Developer Solution
Architecture: Child Theme of Boost + AMD Module (JS) + User Preference API.
Implementation:
- SCSS: Define `:root` variables for colors. `[data-theme="dark"] { --bg: #000; }`.
- JS (AMD): Handle click, toggle attribute on `body`, send AJAX to save.
- PHP: In `theme_config.php` or `lib.php`, inject the saved class/attribute on page load.
// theme/university/amd/src/darkmode.js
define(['jquery', 'core/ajax', 'core/str'], function($, Ajax, Str) {
return {
init: function() {
$('#theme-toggle').on('click', function(e) {
e.preventDefault();
var body = $('body');
var currentMode = body.attr('data-theme');
var newMode = currentMode === 'dark' ? 'light' : 'dark';
// Apply immediately for UX
body.attr('data-theme', newMode);
// Persist to DB
Ajax.call([{
methodname: 'core_user_update_user_preferences',
args: {
preferences: [{
type: 'theme_university_mode',
value: newMode
}]
}
}]);
});
}
};
});
User Story: The "Engineering 101" course takes 15 seconds to load. It has 50 sections and 1000+ resources. It needs to load in under 3 seconds.
Moodle loads all activities in the DOM by default. Rendering 1000+ activity icons and completion checkboxes kills the server and browser.
👨💻 Senior Developer Solution
Architecture: "One Section Per Page" setting is the easy fix. If "All Sections" is required, we need Lazy Loading.
Implementation:
- Configuration: Change Course Format to "One Section per page".
- Code Fix (Course Format Plugin): If using a custom format (e.g., `format_tiles`), implement AJAX loading for sections. Only render the visible section.
- Database: Check for N+1 queries. Ensure `get_fast_modinfo()` is being used and cached properly.
// format/tiles/format.php
// Instead of printing all sections:
if ($course->coursedisplay == COURSE_DISPLAY_MULTIPAGE) {
// Only print current section
print_section($course, $thissection);
} else {
// Use AJAX to fetch content when user clicks a tile
echo "Load Content";
// JS will handle the click:
// fetch('ajax/get_section_content.php?id=' + id).then(...)
}
Developer Note: Always check the "Performance Info" in footer. If DB queries are > 100, you have a plugin problem.
User Story: To comply with GDPR, we need a button to "Anonymize Old Users" (inactive > 2 years). Their grades must remain for statistics, but names/emails must be scrubbed.
Moodle has a "Data Privacy" tool, but it deletes users. We need to keep the record ID but scramble the PII (Personally Identifiable Information).
👨💻 Senior Developer Solution
Architecture: Custom Admin Tool (`tool_anonymize`).
Implementation:
- Selection: Query users with `lastaccess < (time() - 2*YEAR)`.
- Anonymization: Update `mdl_user` table. Set `firstname = 'Anonymized'`, `lastname = 'User'`, `email = md5(id)@deleted.com`.
- Cleanup: Delete profile pictures, custom fields.
- Logging: Log the action in `mdl_logstore_standard_log`.
// admin/tool/anonymize/classes/task/scrub_users.php
namespace tool_anonymize\task;
class scrub_users extends \core\task\scheduled_task {
public function execute() {
global $DB;
// 2 years in seconds
$cutoff = time() - (2 * 365 * 24 * 60 * 60);
$sql = "SELECT id FROM {user} WHERE lastaccess < :time AND deleted = 0 AND id > 2"; // Skip guest/admin
$users = $DB->get_records_sql($sql, ['time' => $cutoff]);
foreach ($users as $u) {
$update = new \stdClass();
$update->id = $u->id;
$update->firstname = 'Anonymized';
$update->lastname = 'User ' . $u->id;
$update->email = $u->id . '@anonymized.local';
$update->picture = 0;
$update->description = '';
// Use core function to ensure cache clearing and events
user_update_user($update);
// Kill sessions
\core\session\manager::kill_user_sessions($u->id);
mtrace("Anonymized user $u->id");
}
}
}
Warning: This is irreversible. Always backup DB first.