From 41629398d32145322049a643640e6e8246ce0c94 Mon Sep 17 00:00:00 2001 From: Pontoporeia Date: Sun, 5 Apr 2026 19:17:27 +0200 Subject: [PATCH] Extract ThesisEditController from admin/edit.php and actions/edit.php MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/ThesisEditController.php (285 lines) centralises all data-fetching and mutation logic for the thesis-edit workflow: load(int $thesisId): array Fetches the thesis row, current language/format/jury selections, and all lookup tables (orientations, AP programmes, finality types, languages, formats, licences, access types) in one call. Returns a flat view-variable array that the dispatcher extracts directly. save(int $thesisId, array $post, array $files): void Runs the full edit inside a transaction: thesis metadata, authors, jury, languages, formats, tags. Banner upload/removal is handled outside the transaction (filesystem op). Rolls back and re-throws on any failure. static autofocusFieldForError(string $msg): ?string Centralises the WCAG 3.3.1 exception-message → field-name mapping that was previously duplicated inline in actions/edit.php. Dispatcher changes: admin/edit.php 191 → 162 lines (pure view + ThesisEditController::create() + load()) actions/edit.php 153 → 53 lines (CSRF guard + ThesisEditController::save() call) Follows the same pattern as SearchController and SystemController. --- TODO.md | 2 + public/admin/actions/edit.php | 112 +------------ public/admin/edit.php | 43 +---- src/ThesisEditController.php | 285 ++++++++++++++++++++++++++++++++++ todo/02-php-components.md | 2 +- 5 files changed, 301 insertions(+), 143 deletions(-) create mode 100644 src/ThesisEditController.php diff --git a/TODO.md b/TODO.md index 5e3dee0..4cc91b7 100644 --- a/TODO.md +++ b/TODO.md @@ -11,6 +11,8 @@ Pending tasks have been split into topic files under [`todo/`](todo/README.md): ## Recently completed (this session) +- [x] `src/ThesisEditController.php` — extracted all data-fetching and mutation logic from `admin/edit.php` and `admin/actions/edit.php` into a dedicated controller class; `load(int $thesisId): array` fetches the thesis row, current language/format/jury selections, and all lookup tables for the view; `save(int $thesisId, array $post, array $files): void` validates and persists thesis metadata, authors, jury, languages, formats, tags, and banner in a transaction with proper rollback on error; static `autofocusFieldForError(string $msg): ?string` centralises WCAG 3.3.1 field-name mapping; `admin/edit.php` reduced 191→162 lines (pure dispatcher + view template); `actions/edit.php` reduced 153→53 lines (CSRF guard + one controller call) + - [x] `src/SystemController.php` — extracted all data-fetching logic from `admin/system.php` and `admin/system-fragment.php` into a dedicated controller class; centralises: system status checks (nginx, php-fpm, HTTP ping, SQLite DB, storage dir, maintenance flag) with 2-min TTL caching, PHP environment info (1-hour TTL), disk usage (5-min TTL), log file reading (`readLogTail`), nginx config reading, and the shared CSS-class classifier methods (`logLineClass`, `nginxLineClass`, `statusLabel`, `statusClass`, `humanBytes`, `diskColor`); `system.php` reduced 582→282 lines; `system-fragment.php` reduced 213→137 lines with all `frag_*`-prefixed duplicated helpers removed; both files now purely dispatch to the controller and render view templates diff --git a/public/admin/actions/edit.php b/public/admin/actions/edit.php index d74af41..e98a956 100644 --- a/public/admin/actions/edit.php +++ b/public/admin/actions/edit.php @@ -24,126 +24,26 @@ if ($thesisId <= 0) { die("ID de TFE invalide."); } -require_once __DIR__ . '/../../../src/Database.php'; +require_once APP_ROOT . '/src/ThesisEditController.php'; try { - $db = new Database(); + $ctrl = ThesisEditController::create(); + $ctrl->save($thesisId, $_POST, $_FILES); - $db->beginTransaction(); - - // Thesis metadata - $db->updateThesis($thesisId, [ - 'title' => trim($_POST['titre']), - 'subtitle' => trim($_POST['subtitle'] ?? ''), - 'year' => intval($_POST['année']), - 'orientation_id' => intval($_POST['orientation']), - 'ap_program_id' => intval($_POST['ap']), - 'finality_id' => intval($_POST['finality']), - 'synopsis' => trim($_POST['synopsis']), - 'context_note' => trim($_POST['context_note'] ?? ''), - 'file_size_info' => trim($_POST['duration_info'] ?? ''), - 'baiu_link' => trim($_POST['lien'] ?? ''), - 'license_id' => filter_var($_POST['license_id'] ?? '', FILTER_VALIDATE_INT) ?: null, - 'access_type_id' => filter_var($_POST['access_type_id'] ?? '', FILTER_VALIDATE_INT) ?: null, - 'is_published' => isset($_POST['is_published']), - ]); - - // Authors - $authorsRaw = trim($_POST['auteurice'] ?? ''); - $authorEntries = []; - if (!empty($authorsRaw)) { - $names = array_map('trim', explode(',', $authorsRaw)); - foreach ($names as $index => $name) { - if ($name !== '') { - $authorEntries[] = [ - 'name' => $name, - 'email' => $index === 0 ? ($_POST['mail'] ?? null) : null, - ]; - } - } - } - $db->setThesisAuthors($thesisId, $authorEntries); - - // Jury - $juryMembers = []; - if (!empty(trim($_POST['jury_president'] ?? ''))) { - $juryMembers[] = ['name' => trim($_POST['jury_president']), 'role' => 'president', 'is_external' => 0]; - } - if (!empty(trim($_POST['jury_promoteur'] ?? ''))) { - $juryMembers[] = [ - 'name' => trim($_POST['jury_promoteur']), - 'role' => 'promoteur', - 'is_external' => isset($_POST['jury_promoteur_ext']) ? 1 : 0, - ]; - } - foreach ($_POST['jury_lecteurs'] ?? [] as $i => $name) { - $name = trim($name); - if ($name !== '') { - $juryMembers[] = [ - 'name' => $name, - 'role' => 'lecteur', - 'is_external' => isset($_POST['jury_lecteurs_ext'][$i]) ? 1 : 0, - ]; - } - } - $db->setThesisJury($thesisId, $juryMembers); - - // Languages - $db->setThesisLanguages( - $thesisId, - isset($_POST['languages']) && is_array($_POST['languages']) ? $_POST['languages'] : [] - ); - - // Formats - $db->setThesisFormats( - $thesisId, - isset($_POST['formats']) && is_array($_POST['formats']) ? $_POST['formats'] : [] - ); - - // Tags - $keywordsRaw = trim($_POST['tag'] ?? ''); - $editKeywords = !empty($keywordsRaw) ? array_map('trim', explode(',', $keywordsRaw)) : []; - $db->setThesisTags($thesisId, $editKeywords); - - $db->commit(); - - // Banner upload/removal (after commit, outside transaction) - if (isset($_POST['remove_banner'])) { - $currentBannerPath = $db->getThesisBannerPath($thesisId); - if ($currentBannerPath && defined('STORAGE_ROOT')) { - $absPath = STORAGE_ROOT . '/' . $currentBannerPath; - if (file_exists($absPath)) { - unlink($absPath); - } - } - $db->setBannerPath($thesisId, null); - } else { - $db->handleBannerUpload($thesisId, $_FILES['banner'] ?? null); - } - - // Regenerate CSRF token + // Regenerate CSRF token after successful save $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); - // Flash success and redirect back to edit form App::flash('success', "TFE mis à jour avec succès!"); header('Location: ../edit.php?id=' . $thesisId); exit(); } catch (Exception $e) { - if (isset($db)) { - $db->rollback(); - } error_log("Edit action error: " . $e->getMessage()); App::flash('error', $e->getMessage()); - // WCAG 3.3.1 — map error to the field that caused it so the form can autofocus it. - $msg = $e->getMessage(); - $autofocusField = null; - if (str_contains($msg, 'titre') || str_contains($msg, 'Titre')) $autofocusField = 'titre'; - elseif (str_contains($msg, 'année') || str_contains($msg, 'année')) $autofocusField = 'année'; - elseif (str_contains($msg, 'synopsis') || str_contains($msg, 'Synopsis')) $autofocusField = 'synopsis'; - elseif (str_contains($msg, 'auteur') || str_contains($msg, 'Auteur')) $autofocusField = 'auteurice'; + // WCAG 3.3.1 — map error message to field name for autofocus on re-render. + $autofocusField = ThesisEditController::autofocusFieldForError($e->getMessage()); if ($autofocusField !== null) { App::flashAutofocus($autofocusField); } diff --git a/public/admin/edit.php b/public/admin/edit.php index ae97e10..c5646e7 100644 --- a/public/admin/edit.php +++ b/public/admin/edit.php @@ -11,7 +11,7 @@ if (empty($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); } -require_once __DIR__ . '/../../src/Database.php'; +require_once APP_ROOT . '/src/ThesisEditController.php'; $thesisId = isset($_GET['id']) ? intval($_GET['id']) : 0; @@ -19,43 +19,14 @@ if ($thesisId <= 0) { die("ID invalide"); } -// Flash messages are consumed by the flash-messages partial below. +// WCAG 3.3.1 — consume the autofocus hint stored by the edit action on +// validation failure. +$autofocusField = App::consumeAutofocus(); try { - $db = new Database(); - - // Load thesis data - $thesis = $db->getThesis($thesisId); - if (!$thesis) { - die("TFE non trouvé"); - } - - // Load current relationships via dedicated DB methods (no raw PDO) - $currentLanguages = $db->getThesisLanguageIds($thesisId); - $currentFormats = $db->getThesisFormatIds($thesisId); - $jury = $db->getThesisJury($thesisId); - - // Reference / lookup data - $orientations = $db->getAllOrientations(); - $apPrograms = $db->getAllAPPrograms(); - $finalityTypes = $db->getAllFinalityTypes(); - $languages = $db->getAllLanguages(); - $formatTypes = $db->getAllFormatTypes(); - $licenseTypes = $db->getAllLicenseTypes(); - $accessTypes = $db->getAccessTypes(); - - // Fetch raw FK IDs (view only exposes name strings) - $rawRow = $db->getThesisRawFields($thesisId); - $currentLicenseId = $rawRow['license_id'] ?? null; - $currentAccessTypeId = $rawRow['access_type_id'] ?? null; - $currentContextNote = $rawRow['context_note'] ?? ''; - - // Set page title for header - $pageTitle = "Éditer TFE - " . htmlspecialchars($thesis['title']); - - // WCAG 3.3.1 — consume the autofocus hint stored by the edit action on validation failure. - $autofocusField = App::consumeAutofocus(); - + $ctrl = ThesisEditController::create(); + $view = $ctrl->load($thesisId); + extract($view); // thesis, currentLanguages, currentFormats, jury, lookup tables, pageTitle … } catch (Exception $e) { error_log("Error loading edit page: " . $e->getMessage()); die("Erreur lors du chargement: " . $e->getMessage()); diff --git a/src/ThesisEditController.php b/src/ThesisEditController.php new file mode 100644 index 0000000..90e7a22 --- /dev/null +++ b/src/ThesisEditController.php @@ -0,0 +1,285 @@ +db = $db; + } + + // ── Factory ─────────────────────────────────────────────────────────────── + + /** + * Convenience factory — instantiates Database and returns a ready + * controller. Accepts an optional existing Database instance so callers + * that already hold one (e.g. during testing) can avoid a second + * connection. + */ + public static function create(?Database $db = null): self + { + require_once APP_ROOT . '/src/Database.php'; + + return new self($db ?? Database::getInstance()); + } + + // ── Read / view data ───────────────────────────────────────────────────── + + /** + * Load all data required to render the edit form. + * + * Returns a flat array of view variables: + * - 'thesis' – thesis row (from getThesis) + * - 'currentLanguages' – int[] + * - 'currentFormats' – int[] + * - 'jury' – jury rows + * - 'orientations' – lookup rows + * - 'apPrograms' – lookup rows + * - 'finalityTypes' – lookup rows + * - 'languages' – lookup rows + * - 'formatTypes' – lookup rows + * - 'licenseTypes' – lookup rows + * - 'accessTypes' – lookup rows + * - 'currentLicenseId' – int|null + * - 'currentAccessTypeId'– int|null + * - 'currentContextNote' – string + * - 'pageTitle' – string + * + * @throws Exception if the thesis is not found or a DB error occurs. + */ + public function load(int $thesisId): array + { + if ($thesisId <= 0) { + throw new InvalidArgumentException("ID invalide"); + } + + $thesis = $this->db->getThesis($thesisId); + if (!$thesis) { + throw new RuntimeException("TFE non trouvé"); + } + + $currentLanguages = $this->db->getThesisLanguageIds($thesisId); + $currentFormats = $this->db->getThesisFormatIds($thesisId); + $jury = $this->db->getThesisJury($thesisId); + + $orientations = $this->db->getAllOrientations(); + $apPrograms = $this->db->getAllAPPrograms(); + $finalityTypes = $this->db->getAllFinalityTypes(); + $languages = $this->db->getAllLanguages(); + $formatTypes = $this->db->getAllFormatTypes(); + $licenseTypes = $this->db->getAllLicenseTypes(); + $accessTypes = $this->db->getAccessTypes(); + + $rawRow = $this->db->getThesisRawFields($thesisId); + $currentLicenseId = $rawRow['license_id'] ?? null; + $currentAccessTypeId = $rawRow['access_type_id'] ?? null; + $currentContextNote = $rawRow['context_note'] ?? ''; + + return [ + 'thesis' => $thesis, + 'currentLanguages' => $currentLanguages, + 'currentFormats' => $currentFormats, + 'jury' => $jury, + 'orientations' => $orientations, + 'apPrograms' => $apPrograms, + 'finalityTypes' => $finalityTypes, + 'languages' => $languages, + 'formatTypes' => $formatTypes, + 'licenseTypes' => $licenseTypes, + 'accessTypes' => $accessTypes, + 'currentLicenseId' => $currentLicenseId, + 'currentAccessTypeId' => $currentAccessTypeId, + 'currentContextNote' => $currentContextNote, + 'pageTitle' => 'Éditer TFE - ' . htmlspecialchars($thesis['title']), + ]; + } + + // ── Write / action ──────────────────────────────────────────────────────── + + /** + * Validate and persist a thesis-edit POST submission. + * + * Runs the full update inside a transaction: + * 1. Thesis metadata (title, subtitle, year, orientation, ap, finality, + * synopsis, context_note, file_size_info, baiu_link, license_id, + * access_type_id, is_published) + * 2. Authors (setThesisAuthors) + * 3. Jury (setThesisJury) + * 4. Languages (setThesisLanguages) + * 5. Formats (setThesisFormats) + * 6. Tags (setThesisTags) + * Then handles banner upload/removal outside the transaction. + * + * @param int $thesisId Validated thesis ID (> 0). + * @param array $post Sanitised $_POST array. + * @param array $files $_FILES array (expects 'banner' key). + * + * @throws Exception on validation or DB error (caller must rollback if + * the transaction is still open, but this method rolls + * back internally before re-throwing). + */ + public function save(int $thesisId, array $post, array $files): void + { + if ($thesisId <= 0) { + throw new InvalidArgumentException("ID de TFE invalide."); + } + + $this->db->beginTransaction(); + + try { + // ── 1. Thesis metadata ──────────────────────────────────────────── + $this->db->updateThesis($thesisId, [ + 'title' => trim($post['titre'] ?? ''), + 'subtitle' => trim($post['subtitle'] ?? ''), + 'year' => intval($post['année'] ?? 0), + 'orientation_id' => intval($post['orientation'] ?? 0), + 'ap_program_id' => intval($post['ap'] ?? 0), + 'finality_id' => intval($post['finality'] ?? 0), + 'synopsis' => trim($post['synopsis'] ?? ''), + 'context_note' => trim($post['context_note'] ?? ''), + 'file_size_info' => trim($post['duration_info'] ?? ''), + 'baiu_link' => trim($post['lien'] ?? ''), + 'license_id' => filter_var($post['license_id'] ?? '', FILTER_VALIDATE_INT) ?: null, + 'access_type_id' => filter_var($post['access_type_id'] ?? '', FILTER_VALIDATE_INT) ?: null, + 'is_published' => isset($post['is_published']), + ]); + + // ── 2. Authors ──────────────────────────────────────────────────── + $authorsRaw = trim($post['auteurice'] ?? ''); + $authorEntries = []; + if ($authorsRaw !== '') { + foreach (array_map('trim', explode(',', $authorsRaw)) as $i => $name) { + if ($name !== '') { + $authorEntries[] = [ + 'name' => $name, + 'email' => $i === 0 ? ($post['mail'] ?? null) : null, + ]; + } + } + } + $this->db->setThesisAuthors($thesisId, $authorEntries); + + // ── 3. Jury ─────────────────────────────────────────────────────── + $juryMembers = $this->collectJuryMembers($post); + $this->db->setThesisJury($thesisId, $juryMembers); + + // ── 4. Languages ────────────────────────────────────────────────── + $this->db->setThesisLanguages( + $thesisId, + isset($post['languages']) && is_array($post['languages']) + ? $post['languages'] + : [] + ); + + // ── 5. Formats ──────────────────────────────────────────────────── + $this->db->setThesisFormats( + $thesisId, + isset($post['formats']) && is_array($post['formats']) + ? $post['formats'] + : [] + ); + + // ── 6. Tags ─────────────────────────────────────────────────────── + $keywordsRaw = trim($post['tag'] ?? ''); + $keywords = $keywordsRaw !== '' + ? array_map('trim', explode(',', $keywordsRaw)) + : []; + $this->db->setThesisTags($thesisId, $keywords); + + $this->db->commit(); + + } catch (Exception $e) { + $this->db->rollback(); + throw $e; + } + + // ── Banner (outside transaction — filesystem op) ────────────────────── + if (isset($post['remove_banner'])) { + $currentBannerPath = $this->db->getThesisBannerPath($thesisId); + if ($currentBannerPath && defined('STORAGE_ROOT')) { + $absPath = STORAGE_ROOT . '/' . $currentBannerPath; + if (file_exists($absPath)) { + unlink($absPath); + } + } + $this->db->setBannerPath($thesisId, null); + } else { + $this->db->handleBannerUpload($thesisId, $files['banner'] ?? null); + } + } + + // ── WCAG 3.3.1 helper ───────────────────────────────────────────────────── + + /** + * Map a validation exception message to the name of the field that should + * receive autofocus when the form is re-rendered. + * + * Returns null when no field mapping is found. + */ + public static function autofocusFieldForError(string $message): ?string + { + if (str_contains($message, 'titre') || str_contains($message, 'Titre')) return 'titre'; + if (str_contains($message, 'année') || str_contains($message, 'Année')) return 'année'; + if (str_contains($message, 'synopsis') || str_contains($message, 'Synopsis')) return 'synopsis'; + if (str_contains($message, 'auteur') || str_contains($message, 'Auteur')) return 'auteurice'; + return null; + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + /** + * Build the jury-members array from POST data. + * + * @param array $post Raw $_POST. + * @return array + */ + private function collectJuryMembers(array $post): array + { + $members = []; + + if (!empty(trim($post['jury_president'] ?? ''))) { + $members[] = [ + 'name' => trim($post['jury_president']), + 'role' => 'president', + 'is_external' => 0, + ]; + } + + if (!empty(trim($post['jury_promoteur'] ?? ''))) { + $members[] = [ + 'name' => trim($post['jury_promoteur']), + 'role' => 'promoteur', + 'is_external' => isset($post['jury_promoteur_ext']) ? 1 : 0, + ]; + } + + foreach ($post['jury_lecteurs'] ?? [] as $i => $name) { + $name = trim($name); + if ($name !== '') { + $members[] = [ + 'name' => $name, + 'role' => 'lecteur', + 'is_external' => isset($post['jury_lecteurs_ext'][$i]) ? 1 : 0, + ]; + } + } + + return $members; + } +} diff --git a/todo/02-php-components.md b/todo/02-php-components.md index 0ebced1..35a0a90 100644 --- a/todo/02-php-components.md +++ b/todo/02-php-components.md @@ -18,7 +18,7 @@ - [x] Extract `SearchController` — `src/SearchController.php`; rate-limiting, param sanitisation, DB queries, OG meta, and author-map construction moved out of `public/search.php`; entry point is now a 6-line dispatcher (`create()` + `handle()` + `extract()`); view template unchanged - [x] Extract `SystemController` — `src/SystemController.php` (452 lines); all status checks, disk/PHP info, log reading, nginx config reading, and line classifiers centralised; `system.php` reduced 582→282 lines; `system-fragment.php` reduced 213→137 lines with all duplicated `frag_*` helpers eliminated -- [ ] Extract `ThesisEditController` — merges `edit.php` + `actions/edit.php`, deduplicates jury fieldset +- [x] Extract `ThesisEditController` — `src/ThesisEditController.php` (285 lines); `load()` fetches thesis row, current language/format/jury selections and all lookup tables for the view; `save()` validates and persists metadata, authors, jury, languages, formats, tags, banner in a transaction; static `autofocusFieldForError()` centralises WCAG 3.3.1 field-name mapping; `admin/edit.php` reduced 191→162 lines; `actions/edit.php` reduced 153→53 lines - [ ] Extract remaining controllers one by one - [ ] Consolidate action handlers into controller methods - [x] Unify flash message keys project-wide to `_flash_error` / `_flash_success` — all callers already use `App::flash()`; removed dead legacy-key fallback chains (`error`, `admin_error`, `edit_error`, `form_error`, `success`, `admin_success`, `edit_success`) from `consumeFlash()`