Differences Between: [Versions 310 and 403] [Versions 311 and 403] [Versions 39 and 403] [Versions 400 and 403] [Versions 401 and 403] [Versions 402 and 403]
1 <?php 2 // This file is part of Moodle - http://moodle.org/ 3 // 4 // Moodle is free software: you can redistribute it and/or modify 5 // it under the terms of the GNU General Public License as published by 6 // the Free Software Foundation, either version 3 of the License, or 7 // (at your option) any later version. 8 // 9 // Moodle is distributed in the hope that it will be useful, 10 // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 // GNU General Public License for more details. 13 // 14 // You should have received a copy of the GNU General Public License 15 // along with Moodle. If not, see <http://www.gnu.org/licenses/>. 16 17 /** 18 * Manual enrolment plugin main library file. 19 * 20 * @package enrol_manual 21 * @copyright 2010 Petr Skoda {@link http://skodak.org} 22 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 */ 24 25 defined('MOODLE_INTERNAL') || die(); 26 27 class enrol_manual_plugin extends enrol_plugin { 28 29 protected $lasternoller = null; 30 protected $lasternollerinstanceid = 0; 31 32 public function roles_protected() { 33 // Users may tweak the roles later. 34 return false; 35 } 36 37 public function allow_enrol(stdClass $instance) { 38 // Users with enrol cap may unenrol other users manually manually. 39 return true; 40 } 41 42 public function allow_unenrol(stdClass $instance) { 43 // Users with unenrol cap may unenrol other users manually manually. 44 return true; 45 } 46 47 public function allow_manage(stdClass $instance) { 48 // Users with manage cap may tweak period and status. 49 return true; 50 } 51 52 /** 53 * Returns link to manual enrol UI if exists. 54 * Does the access control tests automatically. 55 * 56 * @param stdClass $instance 57 * @return moodle_url 58 */ 59 public function get_manual_enrol_link($instance) { 60 $name = $this->get_name(); 61 if ($instance->enrol !== $name) { 62 throw new coding_exception('invalid enrol instance!'); 63 } 64 65 if (!enrol_is_enabled($name)) { 66 return NULL; 67 } 68 69 $context = context_course::instance($instance->courseid, MUST_EXIST); 70 71 if (!has_capability('enrol/manual:enrol', $context)) { 72 // Note: manage capability not used here because it is used for editing 73 // of existing enrolments which is not possible here. 74 return NULL; 75 } 76 77 return new moodle_url('/enrol/manual/manage.php', array('enrolid'=>$instance->id, 'id'=>$instance->courseid)); 78 } 79 80 /** 81 * Return true if we can add a new instance to this course. 82 * 83 * @param int $courseid 84 * @return boolean 85 */ 86 public function can_add_instance($courseid) { 87 global $DB; 88 89 $context = context_course::instance($courseid, MUST_EXIST); 90 if (!has_capability('moodle/course:enrolconfig', $context) or !has_capability('enrol/manual:config', $context)) { 91 return false; 92 } 93 94 if ($DB->record_exists('enrol', array('courseid'=>$courseid, 'enrol'=>'manual'))) { 95 // Multiple instances not supported. 96 return false; 97 } 98 99 return true; 100 } 101 102 /** 103 * Returns edit icons for the page with list of instances. 104 * @param stdClass $instance 105 * @return array 106 */ 107 public function get_action_icons(stdClass $instance) { 108 global $OUTPUT; 109 110 $context = context_course::instance($instance->courseid); 111 112 $icons = array(); 113 if (has_capability('enrol/manual:enrol', $context) or has_capability('enrol/manual:unenrol', $context)) { 114 $managelink = new moodle_url("/enrol/manual/manage.php", array('enrolid'=>$instance->id)); 115 $icons[] = $OUTPUT->action_icon($managelink, new pix_icon('t/enrolusers', get_string('enrolusers', 'enrol_manual'), 'core', array('class'=>'iconsmall'))); 116 } 117 $parenticons = parent::get_action_icons($instance); 118 $icons = array_merge($icons, $parenticons); 119 120 return $icons; 121 } 122 123 /** 124 * Add new instance of enrol plugin with default settings. 125 * @param stdClass $course 126 * @return int id of new instance, null if can not be created 127 */ 128 public function add_default_instance($course) { 129 $expirynotify = $this->get_config('expirynotify', 0); 130 131 $fields = array( 132 'status' => $this->get_config('status'), 133 'roleid' => $this->get_config('roleid', 0), 134 'enrolperiod' => $this->get_config('enrolperiod', 0), 135 'expirynotify' => $expirynotify, 136 'notifyall' => $expirynotify == 2 ? 1 : 0, 137 'expirythreshold' => $this->get_config('expirythreshold', 86400), 138 ); 139 return $this->add_instance($course, $fields); 140 } 141 142 /** 143 * Add new instance of enrol plugin. 144 * @param stdClass $course 145 * @param array instance fields 146 * @return int id of new instance, null if can not be created 147 */ 148 public function add_instance($course, array $fields = NULL) { 149 global $DB; 150 151 if ($DB->record_exists('enrol', array('courseid'=>$course->id, 'enrol'=>'manual'))) { 152 // only one instance allowed, sorry 153 return NULL; 154 } 155 156 return parent::add_instance($course, $fields); 157 } 158 159 /** 160 * Update instance of enrol plugin. 161 * @param stdClass $instance 162 * @param stdClass $data modified instance fields 163 * @return boolean 164 */ 165 public function update_instance($instance, $data) { 166 global $DB; 167 168 // Delete all other instances, leaving only one. 169 if ($instances = $DB->get_records('enrol', array('courseid' => $instance->courseid, 'enrol' => 'manual'), 'id ASC')) { 170 foreach ($instances as $anotherinstance) { 171 if ($anotherinstance->id != $instance->id) { 172 $this->delete_instance($anotherinstance); 173 } 174 } 175 } 176 177 $data->notifyall = $data->expirynotify == 2 ? 1 : 0; 178 179 return parent::update_instance($instance, $data); 180 } 181 182 /** 183 * Returns a button to manually enrol users through the manual enrolment plugin. 184 * 185 * By default the first manual enrolment plugin instance available in the course is used. 186 * If no manual enrolment instances exist within the course then false is returned. 187 * 188 * This function also adds a quickenrolment JS ui to the page so that users can be enrolled 189 * via AJAX. 190 * 191 * @param course_enrolment_manager $manager 192 * @return enrol_user_button 193 */ 194 public function get_manual_enrol_button(course_enrolment_manager $manager) { 195 global $CFG, $PAGE; 196 require_once($CFG->dirroot.'/cohort/lib.php'); 197 198 static $called = false; 199 200 $instance = null; 201 foreach ($manager->get_enrolment_instances() as $tempinstance) { 202 if ($tempinstance->enrol == 'manual') { 203 if ($instance === null) { 204 $instance = $tempinstance; 205 } 206 } 207 } 208 if (empty($instance)) { 209 return false; 210 } 211 212 $link = $this->get_manual_enrol_link($instance); 213 if (!$link) { 214 return false; 215 } 216 217 $button = new enrol_user_button($link, get_string('enrolusers', 'enrol_manual'), 'get'); 218 $button->class .= ' enrol_manual_plugin'; 219 $button->type = single_button::BUTTON_PRIMARY; 220 221 $context = context_course::instance($instance->courseid); 222 $arguments = array('contextid' => $context->id); 223 224 if (!$called) { 225 $called = true; 226 // Calling the following more than once will cause unexpected results. 227 $PAGE->requires->js_call_amd('enrol_manual/quickenrolment', 'init', array($arguments)); 228 } 229 230 return $button; 231 } 232 233 /** 234 * Sync all meta course links. 235 * 236 * @param progress_trace $trace 237 * @param int $courseid one course, empty mean all 238 * @return int 0 means ok, 1 means error, 2 means plugin disabled 239 */ 240 public function sync(progress_trace $trace, $courseid = null) { 241 global $DB; 242 243 if (!enrol_is_enabled('manual')) { 244 $trace->finished(); 245 return 2; 246 } 247 248 // Unfortunately this may take a long time, execution can be interrupted safely here. 249 core_php_time_limit::raise(); 250 raise_memory_limit(MEMORY_HUGE); 251 252 $trace->output('Verifying manual enrolment expiration...'); 253 254 $params = array('now'=>time(), 'useractive'=>ENROL_USER_ACTIVE, 'courselevel'=>CONTEXT_COURSE); 255 $coursesql = ""; 256 if ($courseid) { 257 $coursesql = "AND e.courseid = :courseid"; 258 $params['courseid'] = $courseid; 259 } 260 261 // Deal with expired accounts. 262 $action = $this->get_config('expiredaction', ENROL_EXT_REMOVED_KEEP); 263 264 if ($action == ENROL_EXT_REMOVED_UNENROL) { 265 $instances = array(); 266 $sql = "SELECT ue.*, e.courseid, c.id AS contextid 267 FROM {user_enrolments} ue 268 JOIN {enrol} e ON (e.id = ue.enrolid AND e.enrol = 'manual') 269 JOIN {context} c ON (c.instanceid = e.courseid AND c.contextlevel = :courselevel) 270 WHERE ue.timeend > 0 AND ue.timeend < :now 271 $coursesql"; 272 $rs = $DB->get_recordset_sql($sql, $params); 273 foreach ($rs as $ue) { 274 if (empty($instances[$ue->enrolid])) { 275 $instances[$ue->enrolid] = $DB->get_record('enrol', array('id'=>$ue->enrolid)); 276 } 277 $instance = $instances[$ue->enrolid]; 278 // Always remove all manually assigned roles here, this may break enrol_self roles but we do not want hardcoded hacks here. 279 role_unassign_all(array('userid'=>$ue->userid, 'contextid'=>$ue->contextid, 'component'=>'', 'itemid'=>0), true); 280 $this->unenrol_user($instance, $ue->userid); 281 $trace->output("unenrolling expired user $ue->userid from course $instance->courseid", 1); 282 } 283 $rs->close(); 284 unset($instances); 285 286 } else if ($action == ENROL_EXT_REMOVED_SUSPENDNOROLES or $action == ENROL_EXT_REMOVED_SUSPEND) { 287 $instances = array(); 288 $sql = "SELECT ue.*, e.courseid, c.id AS contextid 289 FROM {user_enrolments} ue 290 JOIN {enrol} e ON (e.id = ue.enrolid AND e.enrol = 'manual') 291 JOIN {context} c ON (c.instanceid = e.courseid AND c.contextlevel = :courselevel) 292 WHERE ue.timeend > 0 AND ue.timeend < :now 293 AND ue.status = :useractive 294 $coursesql"; 295 $rs = $DB->get_recordset_sql($sql, $params); 296 foreach ($rs as $ue) { 297 if (empty($instances[$ue->enrolid])) { 298 $instances[$ue->enrolid] = $DB->get_record('enrol', array('id'=>$ue->enrolid)); 299 } 300 $instance = $instances[$ue->enrolid]; 301 if ($action == ENROL_EXT_REMOVED_SUSPENDNOROLES) { 302 // Remove all manually assigned roles here, this may break enrol_self roles but we do not want hardcoded hacks here. 303 role_unassign_all(array('userid'=>$ue->userid, 'contextid'=>$ue->contextid, 'component'=>'', 'itemid'=>0), true); 304 $this->update_user_enrol($instance, $ue->userid, ENROL_USER_SUSPENDED); 305 $trace->output("suspending expired user $ue->userid in course $instance->courseid, roles unassigned", 1); 306 } else { 307 $this->update_user_enrol($instance, $ue->userid, ENROL_USER_SUSPENDED); 308 $trace->output("suspending expired user $ue->userid in course $instance->courseid, roles kept", 1); 309 } 310 } 311 $rs->close(); 312 unset($instances); 313 314 } else { 315 // ENROL_EXT_REMOVED_KEEP means no changes. 316 } 317 318 $trace->output('...manual enrolment updates finished.'); 319 $trace->finished(); 320 321 return 0; 322 } 323 324 /** 325 * Returns the user who is responsible for manual enrolments in given instance. 326 * 327 * Usually it is the first editing teacher - the person with "highest authority" 328 * as defined by sort_by_roleassignment_authority() having 'enrol/manual:manage' 329 * capability. 330 * 331 * @param int $instanceid enrolment instance id 332 * @return stdClass user record 333 */ 334 protected function get_enroller($instanceid) { 335 global $DB; 336 337 if ($this->lasternollerinstanceid == $instanceid and $this->lasternoller) { 338 return $this->lasternoller; 339 } 340 341 $instance = $DB->get_record('enrol', array('id'=>$instanceid, 'enrol'=>$this->get_name()), '*', MUST_EXIST); 342 $context = context_course::instance($instance->courseid); 343 344 if ($users = get_enrolled_users($context, 'enrol/manual:manage')) { 345 $users = sort_by_roleassignment_authority($users, $context); 346 $this->lasternoller = reset($users); 347 unset($users); 348 } else { 349 $this->lasternoller = parent::get_enroller($instanceid); 350 } 351 352 $this->lasternollerinstanceid = $instanceid; 353 354 return $this->lasternoller; 355 } 356 357 /** 358 * The manual plugin has several bulk operations that can be performed. 359 * @param course_enrolment_manager $manager 360 * @return array 361 */ 362 public function get_bulk_operations(course_enrolment_manager $manager) { 363 global $CFG; 364 require_once($CFG->dirroot.'/enrol/manual/locallib.php'); 365 $context = $manager->get_context(); 366 $bulkoperations = array(); 367 if (has_capability("enrol/manual:manage", $context)) { 368 $bulkoperations['editselectedusers'] = new enrol_manual_editselectedusers_operation($manager, $this); 369 } 370 if (has_capability("enrol/manual:unenrol", $context)) { 371 $bulkoperations['deleteselectedusers'] = new enrol_manual_deleteselectedusers_operation($manager, $this); 372 } 373 return $bulkoperations; 374 } 375 376 /** 377 * Restore instance and map settings. 378 * 379 * @param restore_enrolments_structure_step $step 380 * @param stdClass $data 381 * @param stdClass $course 382 * @param int $oldid 383 */ 384 public function restore_instance(restore_enrolments_structure_step $step, stdClass $data, $course, $oldid) { 385 global $DB; 386 // There is only I manual enrol instance allowed per course. 387 if ($instances = $DB->get_records('enrol', array('courseid'=>$data->courseid, 'enrol'=>'manual'), 'id')) { 388 $instance = reset($instances); 389 $instanceid = $instance->id; 390 } else { 391 $instanceid = $this->add_instance($course, (array)$data); 392 } 393 $step->set_mapping('enrol', $oldid, $instanceid); 394 } 395 396 /** 397 * Restore user enrolment. 398 * 399 * @param restore_enrolments_structure_step $step 400 * @param stdClass $data 401 * @param stdClass $instance 402 * @param int $oldinstancestatus 403 * @param int $userid 404 */ 405 public function restore_user_enrolment(restore_enrolments_structure_step $step, $data, $instance, $userid, $oldinstancestatus) { 406 global $DB; 407 408 // Note: this is a bit tricky because other types may be converted to manual enrolments, 409 // and manual is restricted to one enrolment per user. 410 411 $ue = $DB->get_record('user_enrolments', array('enrolid'=>$instance->id, 'userid'=>$userid)); 412 $enrol = false; 413 if ($ue and $ue->status == ENROL_USER_ACTIVE) { 414 // We do not want to restrict current active enrolments, let's kind of merge the times only. 415 // This prevents some teacher lockouts too. 416 if ($data->status == ENROL_USER_ACTIVE) { 417 if ($data->timestart > $ue->timestart) { 418 $data->timestart = $ue->timestart; 419 $enrol = true; 420 } 421 422 if ($data->timeend == 0) { 423 if ($ue->timeend != 0) { 424 $enrol = true; 425 } 426 } else if ($ue->timeend == 0) { 427 $data->timeend = 0; 428 } else if ($data->timeend < $ue->timeend) { 429 $data->timeend = $ue->timeend; 430 $enrol = true; 431 } 432 } 433 } else { 434 if ($instance->status == ENROL_INSTANCE_ENABLED and $oldinstancestatus != ENROL_INSTANCE_ENABLED) { 435 // Make sure that user enrolments are not activated accidentally, 436 // we do it only here because it is not expected that enrolments are migrated to other plugins. 437 $data->status = ENROL_USER_SUSPENDED; 438 } 439 $enrol = true; 440 } 441 442 if ($enrol) { 443 $this->enrol_user($instance, $userid, null, $data->timestart, $data->timeend, $data->status); 444 } 445 } 446 447 /** 448 * Restore role assignment. 449 * 450 * @param stdClass $instance 451 * @param int $roleid 452 * @param int $userid 453 * @param int $contextid 454 */ 455 public function restore_role_assignment($instance, $roleid, $userid, $contextid) { 456 // This is necessary only because we may migrate other types to this instance, 457 // we do not use component in manual or self enrol. 458 role_assign($roleid, $userid, $contextid, '', 0); 459 } 460 461 /** 462 * Restore user group membership. 463 * @param stdClass $instance 464 * @param int $groupid 465 * @param int $userid 466 */ 467 public function restore_group_member($instance, $groupid, $userid) { 468 global $CFG; 469 require_once("$CFG->dirroot/group/lib.php"); 470 471 // This might be called when forcing restore as manual enrolments. 472 473 groups_add_member($groupid, $userid); 474 } 475 476 /** 477 * Is it possible to delete enrol instance via standard UI? 478 * 479 * @param object $instance 480 * @return bool 481 */ 482 public function can_delete_instance($instance) { 483 $context = context_course::instance($instance->courseid); 484 return has_capability('enrol/manual:config', $context); 485 } 486 487 /** 488 * Is it possible to hide/show enrol instance via standard UI? 489 * 490 * @param stdClass $instance 491 * @return bool 492 */ 493 public function can_hide_show_instance($instance) { 494 $context = context_course::instance($instance->courseid); 495 return has_capability('enrol/manual:config', $context); 496 } 497 498 /** 499 * Enrol all not enrolled cohort members into course via enrol instance. 500 * 501 * @param stdClass $instance 502 * @param int $cohortid 503 * @param int $roleid optional role id 504 * @param int $timestart 0 means unknown 505 * @param int $timeend 0 means forever 506 * @param int $status default to ENROL_USER_ACTIVE for new enrolments, no change by default in updates 507 * @param bool $recovergrades restore grade history 508 * @return int The number of enrolled cohort users 509 */ 510 public function enrol_cohort(stdClass $instance, $cohortid, $roleid = null, $timestart = 0, $timeend = 0, $status = null, $recovergrades = null) { 511 global $DB; 512 $context = context_course::instance($instance->courseid); 513 list($esql, $params) = get_enrolled_sql($context); 514 $sql = "SELECT cm.userid FROM {cohort_members} cm LEFT JOIN ($esql) u ON u.id = cm.userid ". 515 "WHERE cm.cohortid = :cohortid AND u.id IS NULL"; 516 $params['cohortid'] = $cohortid; 517 $members = $DB->get_fieldset_sql($sql, $params); 518 foreach ($members as $userid) { 519 $this->enrol_user($instance, $userid, $roleid, $timestart, $timeend, $status, $recovergrades); 520 } 521 return count($members); 522 } 523 524 /** 525 * We are a good plugin and don't invent our own UI/validation code path. 526 * 527 * @return boolean 528 */ 529 public function use_standard_editing_ui() { 530 return true; 531 } 532 533 /** 534 * Return an array of valid options for the status. 535 * 536 * @return array 537 */ 538 protected function get_status_options() { 539 $options = array(ENROL_INSTANCE_ENABLED => get_string('yes'), 540 ENROL_INSTANCE_DISABLED => get_string('no')); 541 return $options; 542 } 543 544 /** 545 * Return an array of valid options for the roleid. 546 * 547 * @param stdClass $instance 548 * @param context $context 549 * @return array 550 */ 551 protected function get_roleid_options($instance, $context) { 552 if ($instance->id) { 553 $roles = get_default_enrol_roles($context, $instance->roleid); 554 } else { 555 $roles = get_default_enrol_roles($context, $this->get_config('roleid')); 556 } 557 return $roles; 558 } 559 560 /** 561 * Return an array of valid options for the expirynotify. 562 * 563 * @return array 564 */ 565 protected function get_expirynotify_options() { 566 $options = array( 567 0 => get_string('no'), 568 1 => get_string('expirynotifyenroller', 'core_enrol'), 569 2 => get_string('expirynotifyall', 'core_enrol') 570 ); 571 return $options; 572 } 573 574 /** 575 * Add elements to the edit instance form. 576 * 577 * @param stdClass $instance 578 * @param MoodleQuickForm $mform 579 * @param context $context 580 * @return bool 581 */ 582 public function edit_instance_form($instance, MoodleQuickForm $mform, $context) { 583 584 $options = $this->get_status_options(); 585 $mform->addElement('select', 'status', get_string('status', 'enrol_manual'), $options); 586 $mform->addHelpButton('status', 'status', 'enrol_manual'); 587 $mform->setDefault('status', $this->get_config('status')); 588 589 $roles = $this->get_roleid_options($instance, $context); 590 $mform->addElement('select', 'roleid', get_string('defaultrole', 'role'), $roles); 591 $mform->setDefault('roleid', $this->get_config('roleid')); 592 593 $options = array('optional' => true, 'defaultunit' => 86400); 594 $mform->addElement('duration', 'enrolperiod', get_string('defaultperiod', 'enrol_manual'), $options); 595 $mform->setDefault('enrolperiod', $this->get_config('enrolperiod')); 596 $mform->addHelpButton('enrolperiod', 'defaultperiod', 'enrol_manual'); 597 598 $options = $this->get_expirynotify_options(); 599 $mform->addElement('select', 'expirynotify', get_string('expirynotify', 'core_enrol'), $options); 600 $mform->addHelpButton('expirynotify', 'expirynotify', 'core_enrol'); 601 602 $options = array('optional' => false, 'defaultunit' => 86400); 603 $mform->addElement('duration', 'expirythreshold', get_string('expirythreshold', 'core_enrol'), $options); 604 $mform->addHelpButton('expirythreshold', 'expirythreshold', 'core_enrol'); 605 $mform->disabledIf('expirythreshold', 'expirynotify', 'eq', 0); 606 607 if (enrol_accessing_via_instance($instance)) { 608 $warntext = get_string('instanceeditselfwarningtext', 'core_enrol'); 609 $mform->addElement('static', 'selfwarn', get_string('instanceeditselfwarning', 'core_enrol'), $warntext); 610 } 611 } 612 613 /** 614 * Perform custom validation of the data used to edit the instance. 615 * 616 * @param array $data array of ("fieldname"=>value) of submitted data 617 * @param array $files array of uploaded files "element_name"=>tmp_file_path 618 * @param object $instance The instance loaded from the DB 619 * @param context $context The context of the instance we are editing 620 * @return array of "element_name"=>"error_description" if there are errors, 621 * or an empty array if everything is OK. 622 * @return void 623 */ 624 public function edit_instance_validation($data, $files, $instance, $context) { 625 $errors = array(); 626 627 if ($data['expirynotify'] > 0 and $data['expirythreshold'] < 86400) { 628 $errors['expirythreshold'] = get_string('errorthresholdlow', 'core_enrol'); 629 } 630 631 $validstatus = array_keys($this->get_status_options()); 632 $validroles = array_keys($this->get_roleid_options($instance, $context)); 633 $validexpirynotify = array_keys($this->get_expirynotify_options()); 634 635 $tovalidate = array( 636 'status' => $validstatus, 637 'roleid' => $validroles, 638 'enrolperiod' => PARAM_INT, 639 'expirynotify' => $validexpirynotify, 640 'expirythreshold' => PARAM_INT 641 ); 642 643 $typeerrors = $this->validate_param_types($data, $tovalidate); 644 $errors = array_merge($errors, $typeerrors); 645 646 return $errors; 647 } 648 649 /** 650 * Check if enrolment plugin is supported in csv course upload. 651 * 652 * @return bool 653 */ 654 public function is_csv_upload_supported(): bool { 655 return true; 656 } 657 658 } 659 660 /** 661 * Serve the manual enrol users form as a fragment. 662 * 663 * @param array $args List of named arguments for the fragment loader. 664 * @return string 665 */ 666 function enrol_manual_output_fragment_enrol_users_form($args) { 667 $args = (object) $args; 668 $context = $args->context; 669 $o = ''; 670 671 require_capability('enrol/manual:enrol', $context); 672 $mform = new enrol_manual_enrol_users_form(null, $args); 673 674 ob_start(); 675 $mform->display(); 676 $o .= ob_get_contents(); 677 ob_end_clean(); 678 679 return $o; 680 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body