diff --git a/.gitignore b/.gitignore index b5d2236a57..7e70fadd86 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,10 @@ var/* # Ignore node_modules folder node_modules + +# Ignore testing modules +/modules/Courses and Classes +/modules/AccessAPI +/modules/BigBlueButton +/modules/Data Admin + diff --git a/modules/Planner/planner_add.php b/modules/Planner/planner_add.php index a27e3f800f..234574b596 100644 --- a/modules/Planner/planner_add.php +++ b/modules/Planner/planner_add.php @@ -251,12 +251,9 @@ $row->addLabel('timeEnd', __('End Time'))->description(__("Format: hh:mm (24hr)")); $row->addTime('timeEnd')->setValue($nextTimeEnd)->required(); - if (empty($gibbonTTDayRowClassID)) { - $row = $form->addRow(); - $row->addLabel('gibbonSpaceID', __('Location')); - $row->addSelectSpace('gibbonSpaceID') - ->placeholder(); - } + $row = $form->addRow(); + $row->addLabel('gibbonSpaceID', __('Location'))->description(__('Override for timetable location')); + $row->addSelectSpace('gibbonSpaceID')->placeholder(); $form->addRow()->addHeading('Lesson Content', __('Lesson Content')); diff --git a/modules/Planner/planner_edit.php b/modules/Planner/planner_edit.php index 8d03a4f4ea..88c9149287 100644 --- a/modules/Planner/planner_edit.php +++ b/modules/Planner/planner_edit.php @@ -236,13 +236,9 @@ $row->addLabel('timeEnd', __('End Time')); $row->addTime('timeEnd')->required(); - if (empty($values['gibbonTTDayRowClassID'])) { - $row = $form->addRow(); - $row->addLabel('gibbonSpaceID', __('Location')); - $row->addSelectSpace('gibbonSpaceID') - ->placeholder(); - } - + $row = $form->addRow(); + $row->addLabel('gibbonSpaceID', __('Location'))->description(__('Override for timetable location')); + $row->addSelectSpace('gibbonSpaceID')->placeholder(); //LESSON $form->addRow()->addHeading('Lesson Content', __('Lesson Content')); diff --git a/modules/Planner/planner_import.php b/modules/Planner/planner_import.php new file mode 100644 index 0000000000..49177ff44a --- /dev/null +++ b/modules/Planner/planner_import.php @@ -0,0 +1,57 @@ +addError(__('You do not have access to this action.')); + return; +} + +$page->breadcrumbs + ->add(__('Planner'), 'planner.php') + ->add(__('Bulk Import Lessons')); + +// Display any import messages or errors stored in session BEFORE the form +$importErrors = $_SESSION['planner_import_errors'] ?? null; +if (!empty($importErrors) && is_array($importErrors)) { + foreach ($importErrors as $err) { + $page->addError($err); + } + unset($_SESSION['planner_import_errors']); +} + +$importMsg = $_SESSION['planner_import_message'] ?? null; +if (!empty($importMsg)) { + $page->addSuccess($importMsg); + unset($_SESSION['planner_import_message']); +} + +$form = Form::create('plannerImport', $session->get('absoluteURL').'/index.php?q=/modules/Planner/planner_importProcess.php'); +$form->setClass('w-full max-w-3xl'); +$form->setFactory(DatabaseFormFactory::create($pdo)); + +$form->addHeaderAction('download', __('Download Template')) + ->setIcon('download') + ->setURL('/modules/Planner/planner_import_export.php') + ->addParam('action', 'downloadTemplate') + ->directLink() + ->displayLabel(); + +$form->addRow()->addHeading(__('Import Lessons from CSV')); + +$row = $form->addRow(); + $row->addLabel('file', __('Upload CSV'))->description(__('Select the CSV file containing lesson data to import.')); + $row->addFileUpload('file')->required(); + +$row = $form->addRow(); + $row->addFooter(); + $row->addSubmit(__('Import')); + +echo $form->getOutput(); + diff --git a/modules/Planner/planner_importProcess.php b/modules/Planner/planner_importProcess.php new file mode 100644 index 0000000000..536304023d --- /dev/null +++ b/modules/Planner/planner_importProcess.php @@ -0,0 +1,634 @@ +get('absoluteURL').'/index.php?q=/modules/'.$session->get('module').'/planner_import.php'; + +// Use planner_edit permission as the guard so import processing follows edit access +if (!isActionAccessible($guid, $connection2, '/modules/Planner/planner_edit.php')) { + $URL .= '&return=error0'; + header("Location: {$URL}"); + exit(); +} + +$action = $_GET['action'] ?? ''; + +// Debug endpoint: output discovered custom fields and aliases +if ($action === 'debugFields') { + $cf = getCustomFieldMap($container); + header('Content-Type: application/json'); + echo json_encode($cf, JSON_PRETTY_PRINT); + exit; +} + +// Helper: discover core planner columns +function getPlannerColumns($pdo) +{ + global $pdo; + $cols = []; + try { + $stmt = $pdo->select("DESCRIBE gibbonPlannerEntry"); + $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC); + foreach ($rows as $row) $cols[] = $row['Field']; + } catch (Exception $e) { + // If we can't query, just return the default core order columns + return ['gibbonUnitID','date','timeStart','timeEnd','name','summary','description','teachersNotes','gibbonSpaceID', + 'homework','homeworkDueDateTime','homeworkTimeCap','homeworkDetails','homeworkSubmission','homeworkSubmissionDateOpen', + 'homeworkSubmissionDrafts','homeworkSubmissionType','homeworkSubmissionRequired','viewableStudents','viewableParents']; + } + $exclude = ['gibbonPlannerEntryID', 'dateCreated', 'dateModified', 'createdBy', 'modifiedBy', 'dateDeleted']; + // Use a blacklist: return all planner columns except internal/system fields so future fields + // are automatically included in the template/import unless explicitly excluded above. + $cols = array_values(array_diff($cols, $exclude)); + return $cols; +} + +// Helper: discover Lesson Plan custom fields (returns header => id) +function getCustomFieldMap($container) +{ + $customFieldGateway = $container->get(\Gibbon\Domain\System\CustomFieldGateway::class); + $map = []; + $aliases = []; + + try { + $grouped = $customFieldGateway->selectCustomFields('Lesson Plan')->fetchGrouped(); + } catch (Exception $e) { + $grouped = []; + } + + $excludeTypes = ['file', 'image', 'link', 'url', 'attachment']; + + foreach ($grouped as $heading => $fields) { + if (empty($fields) || !is_array($fields)) continue; + foreach ($fields as $f) { + $fieldType = strtolower($f['type'] ?? 'text'); + if (in_array($fieldType, $excludeTypes)) continue; + + $context = $f['context'] ?? 'custom'; + $contextSlug = preg_replace('/[^a-z0-9]+/', '_', strtolower($context)); + $nameSlug = preg_replace('/[^a-z0-9]+/', '_', strtolower($f['name'] ?? '')); + $canonical = $contextSlug.'_custom_'.$f['gibbonCustomFieldID'].'_'.($nameSlug ?: $f['gibbonCustomFieldID']); + + $map[$canonical] = [ + 'id' => $f['gibbonCustomFieldID'], + 'type' => $f['type'] ?? 'text', + 'name' => $f['name'] ?? '', + 'context' => $context, + ]; + + $simple = preg_replace('/[^a-z0-9]+/', '_', strtolower($f['name'] ?? '')); + $aliases[$simple] = $canonical; + $aliases[strtolower($f['name'] ?? '')] = $canonical; + $aliases[$contextSlug.'_'.$simple] = $canonical; + $aliases[str_replace('_', ' ', $simple)] = $canonical; + $aliases[preg_replace('/[^a-z0-9]+/', '', strtolower($f['name'] ?? ''))] = $canonical; + } + } + + // Do NOT include a fallback for inactive fields - selectCustomFields() already filters by active='Y' + // This ensures the CSV template only includes fields that are currently active + + return ['map' => $map, 'aliases' => $aliases]; + +} + +// Helper: probe accessible Lesson Planner hooks for import fields +function getHookFields($container) +{ + global $guid, $connection2, $session, $page; + + $hookGateway = $container->get(\Gibbon\Domain\System\HookGateway::class); + $hooks = $hookGateway->getAccessibleHooksByType('Lesson Planner', $container->get('session')->get('gibbonRoleIDCurrent')); + $hookFields = []; + + foreach ($hooks as $hook) { + $include = $container->get('session')->get('absolutePath').'/modules/'.$hook['sourceModuleName'].'/'.$hook['sourceModuleInclude']; + if (!file_exists($include)) continue; + + // Skip hooks that are designed for viewing (not importing) + // They won't provide import fields and can cause fatal errors + if (strpos($hook['sourceModuleInclude'], 'hook_lessonPlannerView') !== false) { + continue; + } + + ob_start(); + try { + include $include; + } catch (Exception $e) { + // ignore + } catch (Throwable $e) { + // catch fatal errors and other issues + } + ob_end_clean(); + + $fn = $hook['sourceModuleName'].'_lessonPlannerImportFields'; + if (function_exists($fn)) { + try { + $fields = $fn(); + if (is_array($fields)) { + foreach ($fields as $key => $label) { + $header = 'hook_'.$hook['sourceModuleName'].'_'.$key; + $hookFields[$header] = $label; + } + } + } catch (Throwable $e) { + // ignore errors from hook function + } + continue; + } + + if (isset($lessonPlannerImportFields) && is_array($lessonPlannerImportFields)) { + foreach ($lessonPlannerImportFields as $key => $label) { + $header = 'hook_'.$hook['sourceModuleName'].'_'.$key; + $hookFields[$header] = $label; + } + unset($lessonPlannerImportFields); + continue; + } + } + + return $hookFields; +} + +// Build a simple lookup table mapping DB/internal fields to user-facing labels +function getHeaderLookup($container, $pdo) +{ + // Core DB -> User labels (ordered) + $coreOrder = [ + 'gibbonUnitID','date','timeStart','timeEnd','name','summary','description','teachersNotes','gibbonSpaceID', + 'homework','homeworkDueDateTime','homeworkTimeCap','homeworkDetails','homeworkSubmission','homeworkSubmissionDateOpen', + 'homeworkSubmissionDrafts','homeworkSubmissionType','homeworkSubmissionRequired','viewableStudents','viewableParents', + ]; + $dbToUser = [ + 'gibbonCourseClassID' => 'Class', + 'gibbonUnitID' => 'Unit', + 'date' => 'Date', + 'timeStart' => 'Start Time', + 'timeEnd' => 'End Time', + 'name' => 'Lesson Name', + 'gibbonSpaceID' => 'Location', + 'summary' => 'Summary', + 'description' => 'Lesson Details', + 'teachersNotes' => "Teacher's Notes", + 'homework' => 'Add Homework', + 'homeworkDueDateTime' => 'Homework Due Date/Time', + 'homeworkTimeCap' => 'Homework Time Cap', + 'homeworkDetails' => 'Homework Details', + 'homeworkSubmission' => 'Online Submission', + 'homeworkSubmissionDateOpen' => 'Submission Open Date', + 'homeworkSubmissionDrafts' => 'Submission Drafts', + 'homeworkSubmissionType' => 'Submission Type', + 'homeworkSubmissionRequired' => 'Submission Required', + 'viewableStudents' => 'Viewable by Students', + 'viewableParents' => 'Viewable by Parents', + ]; + + // Custom fields and hooks + $cf = getCustomFieldMap($container); + $customMap = $cf['map'] ?? []; + $customAliases = $cf['aliases'] ?? []; + $hookFields = getHookFields($container); + + // Build userToDb by using friendly labels for custom fields + $userToDb = []; + foreach ($coreOrder as $db) { + if (isset($dbToUser[$db])) $userToDb[$dbToUser[$db]] = $db; + } + + foreach ($customMap as $canonical => $info) { + $label = $info['name'] ?? $canonical; + // Avoid collisions with core labels + $label = $label ?: $canonical; + $userToDb[$label] = $canonical; + // also accept simple slug forms as labels + $slug = preg_replace('/[^a-z0-9]+/', '_', strtolower($label)); + $userToDb[$slug] = $canonical; + $userToDb[strtolower($label)] = $canonical; + } + + foreach ($hookFields as $header => $label) { + $userToDb[$label] = $header; // header like hook_Module_key + $userToDb[strtolower($label)] = $header; + $userToDb[preg_replace('/[^a-z0-9]+/', '_', strtolower($label))] = $header; + } + + return [ + 'dbToUser' => $dbToUser, + 'userToDb' => $userToDb, + 'customMap' => $customMap, + 'customAliases' => $customAliases, + 'hookFields' => $hookFields, + 'coreOrder' => $coreOrder, + ]; +} + +// Template download: core + custom + hook fields +if ($action === 'downloadTemplate') { + try { + $core = getPlannerColumns($pdo); + + $lookup = getHeaderLookup($container, $pdo); + + $dbToUser = $lookup['dbToUser']; + $userToDb = $lookup['userToDb']; + $customMap = $lookup['customMap']; + $customAliases = $lookup['customAliases']; + $hookFields = $lookup['hookFields']; + $coreOrder = $lookup['coreOrder']; + + // Build ordered headers using friendly labels + // Insert all custom fields immediately after `name` (Lesson Name) and before `summary`. + $headers = []; + $customInserted = false; + foreach ($coreOrder as $db) { + if (in_array($db, $core)) { + $headers[] = $dbToUser[$db] ?? $db; + + // After the Lesson Name, insert all custom fields (friendly labels) + if (!$customInserted && $db === 'name') { + foreach ($customMap as $canonical => $info) { + $label = $info['name'] ?? $canonical; + $headers[] = $label; + } + $customInserted = true; + } + } + } + // Hook fields (labels) + foreach ($hookFields as $header => $label) { + $headers[] = $label; + } + + $examples = []; + foreach ($headers as $h) { + // Generate actual example values (not just format hints) + switch ($h) { + case 'Class': $examples[] = 'COURSE1.CLASSA'; break; + case 'Date': $examples[] = date('Y-m-d'); break; + case 'Start Time': $examples[] = '09:00'; break; + case 'End Time': $examples[] = '10:00'; break; + case 'Lesson Name': $examples[] = 'Introduction to Topic'; break; + case 'Location': $examples[] = ''; break; + case 'Unit': $examples[] = '1'; break; + case 'Summary': $examples[] = 'Key learning objectives'; break; + case 'Lesson Details': $examples[] = 'Detailed content and activities'; break; + case "Teacher's Notes": $examples[] = 'Notes for preparation'; break; + case 'Add Homework': $examples[] = 'Y'; break; + case 'Homework Due Date/Time': $examples[] = date('Y-m-d H:i:s'); break; + case 'Homework Time Cap': $examples[] = '30'; break; + case 'Homework Details': $examples[] = 'Complete exercises 1-5'; break; + case 'Online Submission': $examples[] = 'Y'; break; + case 'Submission Open Date': $examples[] = date('Y-m-d'); break; + case 'Submission Drafts': $examples[] = '2'; break; + case 'Submission Type': $examples[] = 'Link'; break; + case 'Submission Required': $examples[] = 'Y'; break; + case 'Viewable by Students': $examples[] = 'Y'; break; + case 'Viewable by Parents': $examples[] = 'Y'; break; + default: + // Custom fields: use type to generate appropriate example + $type = null; + $fieldId = null; + + // Resolve custom field type + $internal = $userToDb[strtolower($h)] ?? $userToDb[$h] ?? null; + if ($internal && isset($customMap[$internal])) { + $type = strtolower($customMap[$internal]['type'] ?? 'text'); + } else { + // Try by field name in custom map + foreach ($customMap as $canonical => $info) { + if (strtolower($info['name'] ?? '') === strtolower($h)) { + $type = strtolower($info['type'] ?? 'text'); + break; + } + } + } + + // Generate example based on field type + switch ($type) { + case 'date': $examples[] = date('Y-m-d'); break; + case 'time': + case 'timeofday': $examples[] = '14:00'; break; + case 'number': $examples[] = '42'; break; + case 'checkbox': + case 'checkboxes': + case 'yesno': $examples[] = 'Y'; break; + case 'select': + case 'dropdown': $examples[] = 'Option 1'; break; + default: $examples[] = 'Sample value'; break; + } + } + } + + // Prepend Class column (accepts COURSE.CLASS format like FL07.1) + array_unshift($headers, 'Class'); + array_unshift($examples, 'COURSE1.CLASSA'); + + $filename = 'planner_template_'.date('Y-m-d').'.csv'; + + // Output CSV directly (Gibbon pattern) + header('Pragma: public'); + header('Expires: 0'); + header('Cache-Control: must-revalidate, post-check=0, pre-check=0'); + header('Cache-Control: private', false); + header('Content-Type: text/csv; charset=utf-8'); + header('Content-Disposition: attachment; filename="'.preg_replace('/[^a-zA-Z0-9_\-\.]/', '_', $filename).'"'); + + $out = fopen('php://output', 'w'); + if ($out) { + fputcsv($out, $headers); + fputcsv($out, $examples); + fclose($out); + } + exit; + } catch (Exception $e) { + $_SESSION['planner_import_errors'] = [__('Template download failed: ') . $e->getMessage()]; + header('Location: '.$URL.'&return=error1'); + exit; + } catch (Throwable $e) { + $_SESSION['planner_import_errors'] = [__('Template download failed: ') . $e->getMessage()]; + header('Location: '.$URL.'&return=error1'); + exit; + } +} + +// Otherwise: fall back to processing upload (existing behaviour) + +// Expect uploaded file field 'file' +$file = $_FILES['file'] ?? null; + +if (!$file || $file['error'] !== UPLOAD_ERR_OK) { + $_SESSION['planner_import_errors'] = [__('File upload failed.')]; + header('Location: '.$URL); + exit; +} + +// Read CSV +$rows = array_map('str_getcsv', file($file['tmp_name'])); +$headers = array_shift($rows); + +// Build lookup for import mapping +$lookup = getHeaderLookup($container, $pdo); +$userToDb = $lookup['userToDb']; +$customMap = $lookup['customMap']; +$customAliases = $lookup['customAliases']; +$hookFields = $lookup['hookFields']; + +$imported = 0; +$errors = []; + +foreach ($rows as $rowNumber => $row) { + // Skip empty rows + if (count($row) === 1 && trim($row[0]) === '') continue; + + // Ensure row has same number of columns as headers + $numHeaders = count($headers); + $numRow = count($row); + if ($numRow < $numHeaders) { + // Pad missing columns with empty strings + $row = array_pad($row, $numHeaders, ''); + $errors[] = "Row ".($rowNumber+2).": Column count (got {$numRow}, expected {$numHeaders}) - padded missing values"; + } elseif ($numRow > $numHeaders) { + // Truncate extra columns + $row = array_slice($row, 0, $numHeaders); + $errors[] = "Row ".($rowNumber+2).": Column count (got {$numRow}, expected {$numHeaders}) - truncated extra values"; + } + + // Combine headers with row data + $rowData = array_combine($headers, $row); + + // Build reverse-mapped data: use userToDb to extract values by their internal keys + $data = []; + foreach ($rowData as $incomingHeader => $value) { + if ($value === '' || $value === null) continue; + + // Parse Class field (COURSE.CLASS format) into course and class + $internalKey = null; + if (strtolower($incomingHeader) === 'class') { + // Split "COURSE.CLASS" format + if (strpos($value, '.') !== false) { + [$courseCode, $classCode] = explode('.', $value, 2); + $data['course'] = trim($courseCode); + $data['class'] = trim($classCode); + } else { + $errors[] = "Row ".($rowNumber+2).": Class must be in format COURSE.CLASS (e.g., FL07.1)"; + continue 2; // Skip to next row + } + continue; + } else { + // Try userToDb lookup first (for friendly labels and internal keys) + $internalKey = $userToDb[strtolower($incomingHeader)] ?? $userToDb[$incomingHeader] ?? null; + if (!$internalKey) { + // Try case-insensitive match on custom field names + $norm = preg_replace('/[^a-z0-9]+/', '_', strtolower($incomingHeader)); + $internalKey = $customAliases[$norm] ?? null; + } + } + + if ($internalKey) { + $data[$internalKey] = $value; + } + } + + // --- 1. Resolve class --- + $gibbonCourseClassID = resolveClass($connection2, $data['course'] ?? '', $data['class'] ?? ''); + if (!$gibbonCourseClassID) { + $errors[] = "Row ".($rowNumber+2).": Invalid course/class"; + continue; + } + + // --- 2. Extract and validate required fields --- + $date = $data['date'] ?? ''; + $timeStart = $data['timeStart'] ?? ''; + $timeEnd = $data['timeEnd'] ?? ''; + $name = $data['name'] ?? ''; + $description = $data['description'] ?? ''; + + if (!$date || !$timeStart || !$timeEnd || !$name) { + $errors[] = "Row ".($rowNumber+2).": Missing required fields (date, timeStart, timeEnd, name)"; + continue; + } + + // --- 3. Extract custom fields and normalize values --- + $fieldsData = []; + foreach ($customMap as $canonical => $info) { + $id = $info['id']; + $type = strtolower($info['type'] ?? 'text'); + + // Try to find the value by canonical key or by aliases + $value = null; + if (isset($data[$canonical])) { + $value = $data[$canonical]; + } else { + // Try by field name + if (isset($data[$info['name']])) { + $value = $data[$info['name']]; + } + } + + if ($value === '' || $value === null) continue; + + // Normalize checkbox/yesno values to 'Include' + if (in_array($type, ['checkboxes', 'yesno'])) { + $valNorm = trim(strtolower($value)); + $fieldsData[$id] = in_array($valNorm, ['y', 'yes', '1', 'true']) ? 'Include' : $value; + } else { + $fieldsData[$id] = $value; + } + } + + // --- 4. Extract optional fields and normalize enums --- + $summary = $data['summary'] ?? ''; + $teachersNotes = $data['teachersNotes'] ?? ''; + $gibbonSpaceID = resolveSpace($connection2, $data['gibbonSpaceID'] ?? ''); + $homework = normalizeEnum($data['homework'] ?? '', ['N', 'Y'], 'N'); + $homeworkDueDateTime = $data['homeworkDueDateTime'] ?? ''; + $homeworkDetails = $data['homeworkDetails'] ?? ''; + $homeworkSubmission = normalizeEnum($data['homeworkSubmission'] ?? '', ['N', 'Y'], 'N'); + $homeworkSubmissionRequired = normalizeEnumYesNo($data['homeworkSubmissionRequired'] ?? '', 'Optional', 'Required'); + $homeworkSubmissionType = normalizeEnum($data['homeworkSubmissionType'] ?? '', ['', 'Link', 'File', 'Link/File'], ''); + $viewableStudents = normalizeEnum($data['viewableStudents'] ?? '', ['Y', 'N'], 'Y'); + $viewableParents = normalizeEnum($data['viewableParents'] ?? '', ['Y', 'N'], 'N'); + + // --- 5. Insert lesson --- + try { + $insertData = [ + 'gibbonCourseClassID' => $gibbonCourseClassID, + 'date' => $date, + 'timeStart' => $timeStart, + 'timeEnd' => $timeEnd, + 'name' => $name, + 'description' => $description, + 'summary' => $summary, + 'teachersNotes' => $teachersNotes, + 'gibbonSpaceID' => $gibbonSpaceID, + 'homework' => $homework, + 'homeworkDueDateTime' => $homeworkDueDateTime, + 'homeworkDetails' => $homeworkDetails, + 'homeworkSubmission' => $homeworkSubmission, + 'homeworkSubmissionRequired' => $homeworkSubmissionRequired, + 'homeworkSubmissionType' => $homeworkSubmissionType, + 'viewableStudents' => $viewableStudents, + 'viewableParents' => $viewableParents, + 'gibbonPersonIDCreator' => $session->get('gibbonPersonID'), + 'gibbonPersonIDLastEdit' => $session->get('gibbonPersonID'), + 'fields' => !empty($fieldsData) ? json_encode($fieldsData) : null, + ]; + + $sql = 'INSERT INTO gibbonPlannerEntry SET + gibbonCourseClassID=:gibbonCourseClassID, + date=:date, + timeStart=:timeStart, + timeEnd=:timeEnd, + name=:name, + description=:description, + summary=:summary, + teachersNotes=:teachersNotes, + gibbonSpaceID=:gibbonSpaceID, + homework=:homework, + homeworkDueDateTime=:homeworkDueDateTime, + homeworkDetails=:homeworkDetails, + homeworkSubmission=:homeworkSubmission, + homeworkSubmissionRequired=:homeworkSubmissionRequired, + homeworkSubmissionType=:homeworkSubmissionType, + viewableStudents=:viewableStudents, + viewableParents=:viewableParents, + gibbonPersonIDCreator=:gibbonPersonIDCreator, + gibbonPersonIDLastEdit=:gibbonPersonIDLastEdit, + fields=:fields'; + + $stmt = $connection2->prepare($sql); + $stmt->execute($insertData); + + $imported++; + + } catch (PDOException $e) { + $errors[] = "Row ".($rowNumber+2).": Database error: ".$e->getMessage(); + continue; + } +} + +// --- 6. Report results --- +if (!empty($errors)) { + $_SESSION['planner_import_errors'] = $errors; +} + +$_SESSION['planner_import_message'] = sprintf(__('Imported %1$s lessons.'), $imported); + +header('Location: '.$URL); +exit; + + +// ------------------------------------------------------------ +// Helper: normalize enum values to valid DB options +// ------------------------------------------------------------ +function normalizeEnum($value, $validOptions = [], $default = '') +{ + if (empty($value)) return $default; + + $normalized = strtolower(trim($value)); + foreach ($validOptions as $opt) { + if (strtolower($opt) === $normalized) { + return $opt; + } + } + + // If value doesn't match any option, return default + return $default; +} + +// Helper: map Y/N to custom enum values (e.g., Y → 'Required', N → 'Optional') +function normalizeEnumYesNo($value, $noValue = '', $yesValue = '') +{ + if (empty($value)) return $noValue; + + $normalized = strtolower(trim($value)); + if (in_array($normalized, ['y', 'yes', '1', 'true'])) { + return $yesValue; + } else { + return $noValue; + } +} + +// ------------------------------------------------------------ +// Helper: resolve course/class +// ------------------------------------------------------------ +function resolveClass($connection2, $courseShort, $classShort) +{ + if (!$courseShort || !$classShort) { + return false; + } + + $sql = "SELECT gibbonCourseClassID + FROM gibbonCourseClass + JOIN gibbonCourse USING (gibbonCourseID) + WHERE gibbonCourse.nameShort = :course + AND gibbonCourseClass.nameShort = :class"; + + $stmt = $connection2->prepare($sql); + $stmt->execute([ + 'course' => $courseShort, + 'class' => $classShort, + ]); + + return $stmt->fetchColumn() ?: false; +} + +// Helper: resolve space name to gibbonSpaceID +function resolveSpace($connection2, $spaceName) +{ + if (!$spaceName) { + return ''; + } + + // If it's already a number, assume it's a gibbonSpaceID + if (is_numeric($spaceName)) { + return $spaceName; + } + + // Look up space by name + $sql = "SELECT gibbonSpaceID FROM gibbonSpace WHERE name = :name"; + $stmt = $connection2->prepare($sql); + $stmt->execute(['name' => trim($spaceName)]); + + return $stmt->fetchColumn() ?: ''; +} diff --git a/modules/Planner/planner_import_export.php b/modules/Planner/planner_import_export.php new file mode 100644 index 0000000000..b442bff1b2 --- /dev/null +++ b/modules/Planner/planner_import_export.php @@ -0,0 +1,149 @@ +. +*/ + +$_POST['address'] = '/modules/Planner/planner_import.php'; + +// System-wide include +include '../../gibbon.php'; + +$action = $_GET['action'] ?? ''; + +$URL = $session->get('absoluteURL').'/index.php?q=/modules/Planner/planner_import.php'; + +if (isActionAccessible($guid, $connection2, '/modules/Planner/planner_edit.php') == false) { + $URL = $URL.'&return=error0'; + header("Location: {$URL}"); + exit; +} + +if ($action === 'downloadTemplate') { + try { + // Load the helper functions from planner_importProcess.php without executing it + // Extract just the function definitions + $processFile = file_get_contents(__DIR__ . '/planner_importProcess.php'); + + // Extract helper function definitions + preg_match('/function getPlannerColumns\(.*?\n\}/s', $processFile, $matches); + if (!empty($matches)) eval($matches[0]); + + preg_match('/function getCustomFieldMap\(.*?\n\}/s', $processFile, $matches); + if (!empty($matches)) eval($matches[0]); + + preg_match('/function getHookFields\(.*?\n\}/s', $processFile, $matches); + if (!empty($matches)) eval($matches[0]); + + preg_match('/function getHeaderLookup\(.*?\n \}\n\}/s', $processFile, $matches); + if (!empty($matches)) eval($matches[0]); + + $core = getPlannerColumns($pdo); + + $lookup = getHeaderLookup($container, $pdo); + + $dbToUser = $lookup['dbToUser']; + $coreOrder = $lookup['coreOrder']; + $customMap = $lookup['customMap']; + $hookFields = $lookup['hookFields']; + + // Filter custom fields to only include active ones + $customFieldGateway = $container->get(\Gibbon\Domain\System\CustomFieldGateway::class); + $customMap = array_filter($customMap, function($field) { + return ($field['active'] ?? 'Y') === 'Y'; + }); + + // Build ordered headers using friendly labels - match planner_add.php form order + $headers = []; + $customInserted = false; + foreach ($coreOrder as $db) { + if (in_array($db, $core)) { + $headers[] = $dbToUser[$db] ?? $db; + + // After the Lesson Name, insert all custom fields + if (!$customInserted && $db === 'name') { + foreach ($customMap as $canonical => $info) { + $label = $info['name'] ?? $canonical; + $headers[] = $label; + } + $customInserted = true; + } + } + } + // Hook fields + foreach ($hookFields as $header => $label) { + $headers[] = $label; + } + + // Generate example row + $examples = []; + foreach ($headers as $h) { + switch ($h) { + case 'Class': $examples[] = 'COURSE1.CLASSA'; break; + case 'Date': $examples[] = date('Y-m-d'); break; + case 'Start Time': $examples[] = '09:00'; break; + case 'End Time': $examples[] = '10:00'; break; + case 'Lesson Name': $examples[] = 'Introduction to Topic'; break; + case 'Location': $examples[] = 'Room 101'; break; + case 'Unit': $examples[] = '1'; break; + case 'Summary': $examples[] = 'Key learning objectives'; break; + case 'Lesson Details': $examples[] = 'Detailed content and activities'; break; + case "Teacher's Notes": $examples[] = 'Notes for preparation'; break; + case 'Add Homework': $examples[] = 'Y'; break; + case 'Homework Due Date/Time': $examples[] = date('Y-m-d H:i:s'); break; + case 'Homework Time Cap': $examples[] = '30'; break; + case 'Homework Details': $examples[] = 'Complete exercises 1-5'; break; + case 'Online Submission': $examples[] = 'Y'; break; + case 'Submission Open Date': $examples[] = date('Y-m-d'); break; + case 'Submission Drafts': $examples[] = '2'; break; + case 'Submission Type': $examples[] = 'Link'; break; + case 'Submission Required': $examples[] = 'Y'; break; + case 'Viewable by Students': $examples[] = 'Y'; break; + case 'Viewable by Parents': $examples[] = 'Y'; break; + default: $examples[] = 'Sample value'; break; + } + } + + $filename = 'planner_template_'.date('Y-m-d').'.csv'; + + // Output CSV directly + header('Pragma: public'); + header('Expires: 0'); + header('Cache-Control: must-revalidate, post-check=0, pre-check=0'); + header('Cache-Control: private', false); + header('Content-Type: text/csv; charset=utf-8'); + header('Content-Disposition: attachment; filename="'.preg_replace('/[^a-zA-Z0-9_\-\.]/', '_', $filename).'"'); + + $out = fopen('php://output', 'w'); + if ($out) { + fputcsv($out, $headers); + fputcsv($out, $examples); + fclose($out); + } + exit; + } catch (Exception $e) { + $URL = $URL.'&return=error1'; + header("Location: {$URL}"); + exit; + } +} + +// If we get here, invalid action +$URL = $URL.'&return=error1'; +header("Location: {$URL}"); +exit; diff --git a/modules/Planner/planner_view_full.php b/modules/Planner/planner_view_full.php index a749afe8a0..b2e927a7c4 100644 --- a/modules/Planner/planner_view_full.php +++ b/modules/Planner/planner_view_full.php @@ -314,7 +314,13 @@ $col->addColumn('class', __('Class'))->format(Format::using('courseClassName', ['course', 'class'])); $col->addColumn('date', __('Date'))->format(Format::using('date', 'date')); $col->addColumn('time', __('Time'))->format(Format::using('timeRange', ['timeStart', 'timeEnd'])); - $col->addColumn('location', __('Location'))->format(function ($values) {return !empty($values['spaceName']) ? $values['spaceName'] : '';}); + $col->addColumn('location', __('Location'))->addClass('col-span-3')->format(function ($values) { + // Lesson plan location takes precedence as override + if (!empty($values['spaceName'])) { + return $values['spaceName']; + } + return ''; + }); $col->addColumn('summary', __('Summary'))->addClass('col-span-3'); diff --git a/modules/Planner/src/Tables/LessonTable.php b/modules/Planner/src/Tables/LessonTable.php index 1721d9daf4..50de838d47 100644 --- a/modules/Planner/src/Tables/LessonTable.php +++ b/modules/Planner/src/Tables/LessonTable.php @@ -160,7 +160,7 @@ public function create($gibbonSchoolYearID, $gibbonCourseClassID, $gibbonPersonI ->displayLabel(); } - if ($editAccess) { + if ($editAccess) { $table->addHeaderAction('add', __('Add')) ->setURL('/modules/Planner/planner_add.php') ->addParam('gibbonCourseClassID', $gibbonCourseClassID) @@ -168,6 +168,14 @@ public function create($gibbonSchoolYearID, $gibbonCourseClassID, $gibbonPersonI ->addParam('date', $date) ->addParam('viewBy', $viewBy) ->displayLabel(); + + // Import action: opens the Planner import UI + $table->addHeaderAction('import', __('Import')) + ->setURL('/modules/Planner/planner_import.php') + ->addParam('gibbonCourseClassID', $gibbonCourseClassID) + ->addParam('viewBy', $viewBy) + ->setIcon('upload') + ->displayLabel(); } if ($viewBy == 'year') { diff --git a/src/UI/Timetable/Layers/ClassesLayer.php b/src/UI/Timetable/Layers/ClassesLayer.php index 2d1c3bd4f9..6c43a10c11 100644 --- a/src/UI/Timetable/Layers/ClassesLayer.php +++ b/src/UI/Timetable/Layers/ClassesLayer.php @@ -168,6 +168,17 @@ protected function loadItemsByPerson(\DatePeriod $dateRange, TimetableContext $c $planner = $lessons[$class['lessonID']] ?? []; if (!empty($planner)) { unset($lessons[$class['lessonID']]); + + // Use lesson plan location as override if available + if (!empty($planner['plannerRoomName'])) { + $item->set('subtitle', $planner['plannerRoomName']) + ->set('location', $planner['plannerRoomName']); + } + + // Use lesson plan phone if available + if (!empty($planner['plannerRoomPhone'])) { + $item->set('phone', $planner['plannerRoomPhone']); + } } if ($context->get('edit')) {