diff --git a/TODO.md b/TODO.md index bb754f5..28e1d63 100644 --- a/TODO.md +++ b/TODO.md @@ -13,7 +13,6 @@ - [x] `AboutController.php` — removed credits, sourceCode DB loading - [x] `templates/public/about.php` — hardcoded source code URL, hardcoded credits HTML - [x] `apropos.css` — `.apropos-toc-source` styles -- [x] `.gitignore` — ignore `app/storage/logs/*.log` ## Duplicate TFE submission prevention (fixes) - [x] `DuplicateThesisException` — typed exception carrying existing thesis metadata @@ -141,6 +140,14 @@ - [x] Recapitulatif: show promoteur·ice ULB and lecteur·ices interne/externe - [x] Migration: `014_tfe_form_fields.sql` — ALTER + view rebuild +- [x] Fix `Call to undefined function old()` in admin edit page — define `old()` in `app/public/admin/edit.php` (was only in `add.php`) +- [x] Add Note contextuelle and Backoffice fieldsets to admin add form (matching edit form) +- [x] `Database::createThesis()` — add `context_note`, `remarks`, `jury_points`, `exemplaire_baiu`, `exemplaire_erg`, `cc4r` columns +- [x] `ThesisCreateController::validateAndSanitise()` — handle new admin-only fields +- [x] `ThesisCreateController::submit()` — pass new fields to `createThesis()` +- [x] Replace admin E-mail de confirmation fieldset with Contact interne in Backoffice section (add + edit) +- [x] Remove confirmation email sending from add/edit (admin never sent; student partage unchanged) + ## Refactor form structure per spec (student vs admin) - [x] Remove `jury_president` field from student-facing forms (edit keeps it as optional) - [x] Jury: split into promoteur·ice interne, promoteur·ice ULB, lecteur·ice interne, lecteur·ice externe — each with +add button @@ -158,3 +165,18 @@ - [x] Fichiers: cover image hint updated to 4:3 ratio - [x] All three form pages (admin add, admin edit, partage) updated - [x] Controllers updated: `collectJuryMembers`, `validateAndSanitise`, `buildFileSizeInfo`, `license_custom`, `cc2r`→`cc4r` mapping + +## Fix form field required states & missing fields per spec +- [x] Admin add: add `contact_public` checkbox (matching edit form) +- [x] Admin add + partage + admin edit: formats checkbox-list `$required = true` +- [x] All forms: jury promoteur·ice interne `required` attribute +- [x] All forms: jury lecteur·ice interne `required` attribute (at least one) +- [x] All forms: jury lecteur·ice externe `required` attribute (at least one) +- [x] All forms: licence select `$required = true` +- [x] Admin edit: add "E-mail de confirmation" fieldset +- [x] Partage: contact always visible (POST handler defaults `showContact` to true when no `contact_public` key present) +- [x] Partage: filter PACS from AP programs dropdown +- [x] Verify no duplicate asterisks on any field +- [x] Admin add: `contact_public` POST handling in ThesisCreateController for admin submissions +- [x] Server-side validation: formats required, jury members required, licence required (ThesisCreateController + ThesisEditController) +- [x] Autofocus mappings for new validation errors (format, jury, licence) diff --git a/app/public/admin/edit.php b/app/public/admin/edit.php index 50ff58e..61ff2c9 100644 --- a/app/public/admin/edit.php +++ b/app/public/admin/edit.php @@ -21,6 +21,11 @@ $autofocusField = App::consumeAutofocus(); $helpBlocks = Database::getInstance()->getAllFormHelpBlocks(); $helpFn = fn(string $key) => $helpBlocks[$key]['content'] ?? ''; +function old($key, $default = "") { + global $formData; + return isset($formData[$key]) ? htmlspecialchars($formData[$key]) : $default; +} + try { $ctrl = ThesisEditController::create(); $view = $ctrl->load($thesisId); diff --git a/app/public/assets/css/form.css b/app/public/assets/css/form.css index 3ba135d..5ca8d06 100644 --- a/app/public/assets/css/form.css +++ b/app/public/assets/css/form.css @@ -106,12 +106,8 @@ font-weight: 600; } -/* Asterisk appended to labels of required fields */ -label:has(+ input:required:not([type="hidden"]))::after, -label:has(+ select:required)::after, -label:has(+ textarea:required)::after, -label:has(+ div > input:required)::after { - content: " *"; +/* Asterisk on required field labels */ +.asterisk { color: var(--error, #c00); } diff --git a/app/public/partage/index.php b/app/public/partage/index.php index 2afc84d..1722677 100644 --- a/app/public/partage/index.php +++ b/app/public/partage/index.php @@ -196,6 +196,9 @@ function renderShareLinkForm(string $slug, array $link): void die('Erreur lors du chargement du formulaire.'); } + // Filter out PACS from AP programs for student forms (spec: admin-only AP) + $apPrograms = array_values(array_filter($apPrograms, fn($ap) => ($ap['name'] ?? '') !== 'PACS')); + $formData = $_SESSION['form_data_share_' . $slug] ?? []; unset($_SESSION['form_data_share_' . $slug]); @@ -303,7 +306,7 @@ function renderShareLinkForm(string $slug, array $link): void
Format(s) - +
diff --git a/app/src/Controllers/ThesisCreateController.php b/app/src/Controllers/ThesisCreateController.php index 5df8eba..ddd97ea 100644 --- a/app/src/Controllers/ThesisCreateController.php +++ b/app/src/Controllers/ThesisCreateController.php @@ -174,19 +174,25 @@ class ThesisCreateController try { $thesisId = $this->db->createThesis([ - 'year' => $data['annee'], - 'orientation_id' => $data['orientationId'], - 'ap_program_id' => $data['apProgramId'], - 'finality_id' => $data['finalityId'], - 'title' => $data['titre'], - 'subtitle' => $data['subtitle'], - 'synopsis' => $data['synopsis'], - 'file_size_info' => $data['durationInfo'], - 'baiu_link' => $data['lien'], + 'year' => $data['annee'], + 'orientation_id' => $data['orientationId'], + 'ap_program_id' => $data['apProgramId'], + 'finality_id' => $data['finalityId'], + 'title' => $data['titre'], + 'subtitle' => $data['subtitle'], + 'synopsis' => $data['synopsis'], + 'context_note' => $data['contextNote'], + 'file_size_info' => $data['durationInfo'], + 'baiu_link' => $data['lien'], 'license_id' => $data['licenseId'], 'license_custom' => $data['licenseCustom'], 'access_type_id' => $data['accessTypeId'], - 'objet' => $data['objet'], + 'objet' => $data['objet'], + 'remarks' => $data['remarks'], + 'jury_points' => $data['juryPoints'], + 'exemplaire_baiu' => $data['exemplaireBaiu'], + 'exemplaire_erg' => $data['exemplaireErg'], + 'cc4r' => $data['cc4r'], ]); $identifier = $this->db->getThesisIdentifier($thesisId); @@ -247,15 +253,28 @@ class ThesisCreateController if (str_contains($message, 'langue')) { return 'languages'; } + if (str_contains($message, 'promoteur')) { + return 'jury_promoteur'; + } + if (str_contains($message, 'lecteur·ice interne')) { + return 'jury_lecteur_interne[]'; + } + if (str_contains($message, 'lecteur·ice externe')) { + return 'jury_lecteur_externe[]'; + } + if (str_contains($message, 'format')) { + return 'formats'; + } if (str_contains($message, 'mots-clés')) { return 'tag'; } + if (str_contains($message, 'licence')) { + return 'license_id'; + } if (str_contains($message, 'Lien URL')) { return 'lien'; } - if (str_contains($message, 'e-mail de confirmation')) { - return 'confirmation_email'; - } + return null; } @@ -285,7 +304,13 @@ class ThesisCreateController } $mail = !empty($post['mail']) ? $this->sanitiseString($post['mail']) : ''; - $showContact = !empty($post['contact_public']) ? true : false; + // contact_public: respected if present (admin form); defaults to true for student forms + // where the spec says contact is always visible when provided. + if (array_key_exists('contact_public', $post)) { + $showContact = !empty($post['contact_public']); + } else { + $showContact = $mail !== ''; + } $annee = filter_var($post['année'] ?? '', FILTER_VALIDATE_INT); if ($annee === false || $annee < 2000 || $annee > ((int) date('Y') + 1)) { @@ -326,6 +351,9 @@ class ThesisCreateController // Jury members — new structure: separate interne/externe lecteurs $juryMembers = []; + $hasPromoteur = false; + $hasLecteurInt = false; + $hasLecteurExt = false; if (!empty(trim($post['jury_promoteur'] ?? ''))) { $juryMembers[] = [ 'name' => trim($post['jury_promoteur']), @@ -333,6 +361,7 @@ class ThesisCreateController 'is_external' => 0, 'is_ulb' => 0, ]; + $hasPromoteur = true; } if (!empty(trim($post['jury_promoteur_ulb_name'] ?? ''))) { $juryMembers[] = [ @@ -346,12 +375,14 @@ class ThesisCreateController $name = trim($name); if ($name !== '') { $juryMembers[] = ['name' => $name, 'role' => 'lecteur', 'is_external' => 0]; + $hasLecteurInt = true; } } foreach ($post['jury_lecteur_externe'] ?? [] as $name) { $name = trim($name); if ($name !== '') { $juryMembers[] = ['name' => $name, 'role' => 'lecteur', 'is_external' => 1]; + $hasLecteurExt = true; } } // Keep backwards compat with old jury_lecteurs (from old-style forms) @@ -364,6 +395,11 @@ class ThesisCreateController 'role' => 'lecteur', 'is_external' => isset($post['jury_lecteurs_ext'][$i]) ? 1 : 0, ]; + if (isset($post['jury_lecteurs_ext'][$i]) && $post['jury_lecteurs_ext'][$i]) { + $hasLecteurExt = true; + } else { + $hasLecteurInt = true; + } } } } @@ -371,6 +407,16 @@ class ThesisCreateController $juryMembers[] = ['name' => trim($post['jury_president']), 'role' => 'president', 'is_external' => 0]; } + if (!$hasPromoteur) { + throw new Exception('Veuillez indiquer au moins un·e promoteur·ice interne.'); + } + if (!$hasLecteurInt) { + throw new Exception('Veuillez indiquer au moins un·e lecteur·ice interne.'); + } + if (!$hasLecteurExt) { + throw new Exception('Veuillez indiquer au moins un·e lecteur·ice externe.'); + } + // Keywords (max 10) $tagRaw = $this->sanitiseString($post['tag'] ?? ''); $keywords = $tagRaw !== '' ? array_map('trim', explode(',', $tagRaw)) : []; @@ -386,13 +432,19 @@ class ThesisCreateController throw new Exception('Veuillez sélectionner au moins une langue.'); } - // Formats (optional) + // Formats (at least one required) $formatIds = isset($post['formats']) && is_array($post['formats']) ? array_map('intval', $post['formats']) : []; + if (empty($formatIds)) { + throw new Exception('Veuillez sélectionner au moins un format.'); + } $licenseId = filter_var($post['license_id'] ?? '', FILTER_VALIDATE_INT) ?: null; $licenseCustom = trim($post['license_custom'] ?? ''); + if (!$licenseId && $licenseCustom === '') { + throw new Exception('Veuillez sélectionner une licence ou en préciser une.'); + } // Access type — must be one of the enabled types; default 2 (Interne) $accessTypeId = filter_var($post['access_type_id'] ?? '', FILTER_VALIDATE_INT); @@ -413,20 +465,39 @@ class ThesisCreateController } } - // Confirmation e-mail (optional) - $confirmationEmail = trim($post['confirmation_email'] ?? ''); - if ($confirmationEmail !== '') { - $confirmationEmail = filter_var($confirmationEmail, FILTER_VALIDATE_EMAIL); - if ($confirmationEmail === false) { - throw new Exception("L'adresse e-mail de confirmation n'est pas valide."); + // Contact interne (optional, admin-only) + $contactInterne = trim($post['contact_interne'] ?? ''); + if ($contactInterne !== '') { + $contactInterne = filter_var($contactInterne, FILTER_VALIDATE_EMAIL); + if ($contactInterne === false) { + throw new Exception("L'adresse de contact interne n'est pas valide."); } } + // Note contextuelle (optional, max 1500 chars) + $contextNote = $this->sanitiseString($post['context_note'] ?? ''); + if (mb_strlen($contextNote) > 1500) { + $contextNote = mb_substr($contextNote, 0, 1500); + } + + // Backoffice fields (admin only) + $remarks = trim($post['remarks'] ?? ''); + $juryPoints = $post['jury_points'] ?? null; + if ($juryPoints !== null && $juryPoints !== '') { + $juryPoints = filter_var($juryPoints, FILTER_VALIDATE_FLOAT); + if ($juryPoints === false || $juryPoints < 0 || $juryPoints > 20) { + throw new Exception('La note du jury doit être comprise entre 0 et 20.'); + } + } + $exemplaireBaiu = !empty($post['exemplaire_baiu']); + $exemplaireErg = !empty($post['exemplaire_erg']); + $cc4r = !empty($post['cc2r']); + return compact( 'authorNames', 'mail', 'showContact', - 'confirmationEmail', + 'contactInterne', 'annee', 'orientationId', 'apProgramId', @@ -443,7 +514,13 @@ class ThesisCreateController 'licenseCustom', 'lien', 'accessTypeId', - 'objet' + 'objet', + 'contextNote', + 'remarks', + 'juryPoints', + 'exemplaireBaiu', + 'exemplaireErg', + 'cc4r' ); } diff --git a/app/src/Controllers/ThesisEditController.php b/app/src/Controllers/ThesisEditController.php index 9b244bc..a226624 100644 --- a/app/src/Controllers/ThesisEditController.php +++ b/app/src/Controllers/ThesisEditController.php @@ -162,6 +162,54 @@ class ThesisEditController throw new InvalidArgumentException('ID de TFE invalide.'); } + // ── Basic validation (same required fields as create) ────────────────── + $errors = []; + $titre = trim($post['titre'] ?? ''); + if ($titre === '') $errors[] = 'Le titre est requis.'; + $auteurice = trim($post['auteurice'] ?? ''); + if ($auteurice === '') $errors[] = "L'auteur·ice est requis."; + $synopsis = trim($post['synopsis'] ?? ''); + if ($synopsis === '') $errors[] = 'Le synopsis est requis.'; + $annee = intval($post['année'] ?? 0); + if ($annee < 2000 || $annee > ((int)date('Y') + 1)) $errors[] = "L'année est invalide."; + $orientationId = intval($post['orientation'] ?? 0); + if ($orientationId <= 0) $errors[] = "L'orientation est requise."; + $apProgramId = intval($post['ap'] ?? 0); + if ($apProgramId <= 0) $errors[] = "L'atelier pluridisciplinaire est requis."; + $finalityId = intval($post['finality'] ?? 0); + if ($finalityId <= 0) $errors[] = 'La finalité est requise.'; + + // Languages + $langIds = isset($post['languages']) && is_array($post['languages']) ? $post['languages'] : []; + if (empty($langIds)) $errors[] = 'Au moins une langue est requise.'; + + // Formats + $fmtIds = isset($post['formats']) && is_array($post['formats']) ? $post['formats'] : []; + if (empty($fmtIds)) $errors[] = 'Au moins un format est requis.'; + + // Licence + $licenseId = filter_var($post['license_id'] ?? '', FILTER_VALIDATE_INT) ?: null; + $licenseCustom = trim($post['license_custom'] ?? ''); + if (!$licenseId && $licenseCustom === '') $errors[] = 'Une licence est requise.'; + + // Jury + $hasPromoteur = !empty(trim($post['jury_promoteur'] ?? '')); + $hasLecteurInt = false; + $hasLecteurExt = false; + foreach ($post['jury_lecteur_interne'] ?? [] as $n) { + if (trim((string)$n) !== '') { $hasLecteurInt = true; break; } + } + foreach ($post['jury_lecteur_externe'] ?? [] as $n) { + if (trim((string)$n) !== '') { $hasLecteurExt = true; break; } + } + if (!$hasPromoteur) $errors[] = 'Un·e promoteur·ice interne est requis.'; + if (!$hasLecteurInt) $errors[] = 'Au moins un·e lecteur·ice interne est requis.'; + if (!$hasLecteurExt) $errors[] = 'Au moins un·e lecteur·ice externe est requis.'; + + if (!empty($errors)) { + throw new RuntimeException(implode(' ', $errors)); + } + $this->db->beginTransaction(); try { @@ -531,6 +579,33 @@ class ThesisEditController if (str_contains($message, 'auteur') || str_contains($message, 'Auteur')) { return 'auteurice'; } + if (str_contains($message, 'orientation')) { + return 'orientation'; + } + if (str_contains($message, 'atelier')) { + return 'ap'; + } + if (str_contains($message, 'finalité')) { + return 'finality'; + } + if (str_contains($message, 'langue')) { + return 'languages'; + } + if (str_contains($message, 'format')) { + return 'formats'; + } + if (str_contains($message, 'licence')) { + return 'license_id'; + } + if (str_contains($message, 'promoteur')) { + return 'jury_promoteur'; + } + if (str_contains($message, 'lecteur·ice interne')) { + return 'jury_lecteur_interne[]'; + } + if (str_contains($message, 'lecteur·ice externe')) { + return 'jury_lecteur_externe[]'; + } return null; } diff --git a/app/src/Database.php b/app/src/Database.php index 646c85e..6e77e74 100644 --- a/app/src/Database.php +++ b/app/src/Database.php @@ -1848,13 +1848,16 @@ class Database INSERT INTO theses ( identifier, title, subtitle, year, orientation_id, ap_program_id, finality_id, - synopsis, file_size_info, + synopsis, context_note, file_size_info, baiu_link, license_id, license_custom, access_type_id, objet, is_published, + remarks, jury_points, + exemplaire_baiu, exemplaire_erg, + cc4r, submitted_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, CURRENT_TIMESTAMP) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) '); $validObjet = ['tfe', 'thèse', 'frart']; @@ -1869,12 +1872,18 @@ class Database (int)$data['ap_program_id'], (int)$data['finality_id'], $data['synopsis'], + !empty($data['context_note']) ? $data['context_note'] : null, !empty($data['file_size_info']) ? $data['file_size_info'] : null, !empty($data['baiu_link']) ? $data['baiu_link'] : null, $data['license_id'] ?? null, !empty($data['license_custom']) ? $data['license_custom'] : null, isset($data['access_type_id']) ? (int)$data['access_type_id'] : 2, // default: Interne $objet, + !empty($data['remarks']) ? $data['remarks'] : null, + isset($data['jury_points']) && $data['jury_points'] !== '' ? (float)$data['jury_points'] : null, + !empty($data['exemplaire_baiu']) ? 1 : 0, + !empty($data['exemplaire_erg']) ? 1 : 0, + !empty($data['cc4r']) ? 1 : 0, ]); return (int)$this->pdo->lastInsertId(); diff --git a/app/templates/admin/add.php b/app/templates/admin/add.php index 3b526bb..0ef4451 100644 --- a/app/templates/admin/add.php +++ b/app/templates/admin/add.php @@ -16,6 +16,19 @@ include APP_ROOT . '/templates/partials/form/fieldset-tfe-info.php'; ?> + +
+ Contact +
+ + L'adresse est toujours conservée en interne comme contact de référence. +
+
+
Langue(s) @@ -26,7 +39,7 @@
Format(s) - +
@@ -82,10 +95,62 @@ include APP_ROOT . '/templates/partials/form/fieldset-licence-explanation.php'; ?> - +
- E-mail de confirmation - + Note contextuelle +
+ +
+ + Visible publiquement pour les TFE Interne ou Interdit. Max 1 500 caractères. +
+
+
+ + +
+ Backoffice + +
+ + + Note du jury (interne, non visible publiquement). +
+ +
+ + + Notes internes (non visibles publiquement). +
+ +
+ + + Adresse de contact interne (non visible publiquement). +
+ +
+ + Case logistique : cocher si un exemplaire physique est disponible à la BAIU. +
+ +
+ + Case logistique : cocher si un exemplaire physique est disponible à l'ERG. +
@@ -301,6 +301,14 @@ Notes internes (non visibles publiquement). +
+ + + Adresse de contact interne (non visible publiquement). +
+